Compare commits

...

162 Commits

Author SHA1 Message Date
Naida Vatric
ce857ddce9 Renamed var. 2020-02-23 23:11:21 +01:00
Naida Vatric
148b2ea863 Changed default. 2020-02-23 16:38:40 +01:00
Naida Vatric
d436d4a37b Added Scraper API option. 2020-02-22 22:15:27 +01:00
Bilal Catic
6791a509d0 make user agent header configurable through env variable 2020-02-20 21:07:16 +01:00
Bilal Catic
edc6e2bbf7 Merge branch 'create-fetch-wrapper-with-user-agent' into 'master'
Create fetch wrapper with user agent

See merge request saburly/marketalarm/web!98
2020-02-20 19:58:32 +00:00
Bilal Catic
4f230020d7 use fetch wrapper instead of node-fetch 2020-02-20 19:49:29 +01:00
Bilal Catic
f62a7200c7 create fetch wrapper with mandatory user agent header 2020-02-20 19:47:30 +01:00
Bilal Catic
cff7cc2e9c apply prettier 2020-02-20 19:46:39 +01:00
Naida Vatric
df2a962d0f Merge branch 'prostor-vip-ads-fix' into 'master'
Prostor VIP ads fixed.

See merge request saburly/marketalarm/web!94
2020-02-17 14:44:58 +00:00
Naida Vatric
be4508ebea Merge branch 'include-incomplete-ads-inverse' into 'master'
Default true for include incomplete ads.

See merge request saburly/marketalarm/web!96
2020-02-17 14:44:35 +00:00
Naida Vatric
81fa3f046d Default true for include incomplete ads. 2020-02-15 00:52:06 +01:00
Naida Vatric
5bdc8e149a Prostor VIP ads fixed. 2020-02-14 22:41:51 +01:00
Senad Uka
fc7fe3c0b3 Notificaton service disabled 2020-02-14 15:07:42 +01:00
Naida Vatric
b3007123a5 Merge branch 'rename-settings-var' into 'master'
Rename settings var

See merge request saburly/marketalarm/web!93
2020-02-10 20:17:08 +00:00
Naida Vatric
f7d4a9cd07 Renamed settings var to describe purpose. 2020-02-10 21:15:28 +01:00
Naida Vatric
ab6812889a Merge branch 'fixing-saljic-bugs' into 'master'
Fixing saljic bugs

See merge request saburly/marketalarm/web!92
2020-02-09 18:11:00 +00:00
Naida Vatric
b82134e280 Fixed saljic bug for heroku. 2020-02-09 19:09:00 +01:00
Naida Vatric
be378883c8 Just another fix try. 2020-02-08 00:47:00 +01:00
Naida Vatric
8a87b9e253 Another fix. 2020-02-08 00:27:26 +01:00
Naida Vatric
43bc23b164 Another fix. Defined more var. 2020-02-07 22:27:01 +01:00
Naida Vatric
fc6351af46 Added columns and logs for types. 2020-02-07 22:12:53 +01:00
Naida Vatric
6267b2cab4 Merge branch 'staging-tag-to-checkup-email' into 'master'
Added staging tag to checkup email. Email footer bug fixed.

See merge request saburly/marketalarm/web!91
2020-02-06 21:43:56 +00:00
Naida Vatric
97724a47a1 Removed debugg logs. Smal fixes. 2020-02-06 22:40:56 +01:00
Naida Vatric
91a1c6a91e Added more logs for debugging. 2020-02-06 14:12:35 +01:00
Naida Vatric
eb4ab2e341 Changed misspeling. 2020-02-05 23:02:14 +01:00
Naida Vatric
2d0a00b967 Debugging- noOfRealEstates and staging tag. 2020-02-05 21:58:45 +01:00
Naida Vatric
74def9c059 Added staging tag to checkup email. Email footer bug fixed. 2020-02-05 21:35:18 +01:00
Naida Vatric
d29b3eb1b3 Merge branch 'crawler-saljicnekretnine' into 'master'
Crawler saljicnekretnine

See merge request saburly/marketalarm/web!90
2020-02-04 13:09:10 +00:00
Naida Vatric
41b59e8c7c Merge branch 'tag-staging-email' into 'master'
Tag staging email

See merge request saburly/marketalarm/web!85
2020-02-04 13:07:53 +00:00
Naida Vatric
b933fa96d4 Merge branch 'master' into 'tag-staging-email'
# Conflicts:
#   app/config/appConfig.js
2020-02-04 13:07:43 +00:00
Naida Vatric
824db4fbc3 Merge branch 'checkup-email' into 'master'
Added check up email that everything works.

See merge request saburly/marketalarm/web!89
2020-01-31 21:59:35 +00:00
Naida Vatric
0f91841c43 Merge branch 'master' into 'checkup-email'
# Conflicts:
#   app/config/appConfig.js
#   app/services/notificationService.js
2020-01-31 21:59:26 +00:00
Naida Vatric
12b4a8f6ec Merge branch 'ads-without-price' into 'master'
Ads without price

See merge request saburly/marketalarm/web!88
2020-01-31 21:53:36 +00:00
Naida Vatric
17621ad310 Merge branch 'price-history-log' into 'master'
Price history log

See merge request saburly/marketalarm/web!87
2020-01-31 21:53:18 +00:00
Naida Vatric
c461525959 Merge branch 'sliders-formating' into 'master'
Changed sliders for different real estate types.

See merge request saburly/marketalarm/web!86
2020-01-31 21:53:04 +00:00
Naida Vatric
aec9c1e1d5 Merge branch 'more-details-email' into 'master'
More details email

See merge request saburly/marketalarm/web!84
2020-01-31 21:52:33 +00:00
Naida Vatric
2d672f4660 Merge branch 'prostor-vip-ads' into 'master'
Prostor vip ads

See merge request saburly/marketalarm/web!83
2020-01-31 21:52:13 +00:00
Naida Vatric
bc15cf65a5 Merge branch 'results-link' into 'master'
Results link

See merge request saburly/marketalarm/web!82
2020-01-31 21:51:38 +00:00
Naida Vatric
ce11d57ab2 Merge branch 'no-all-results-email' into 'master'
No all results email

See merge request saburly/marketalarm/web!81
2020-01-31 21:51:20 +00:00
Naida Vatric
4a6bcf262e Merge branch 'add-even-more-filters' into 'master'
Add even more filters

See merge request saburly/marketalarm/web!77
2020-01-31 21:50:54 +00:00
Naida Vatric
712cde1632 Changed not to use NODE_ENV variable. 2020-01-31 22:47:47 +01:00
Naida Vatric
1ba7cf8531 Added crawler for Saljic nekretnine. 2020-01-31 22:03:39 +01:00
Naida Vatric
7a7aecb3ee WIP Scraped no of rooms, floors etc. 2020-01-31 00:55:24 +01:00
Naida Vatric
78c4054cde WIP Scraped title, price and location. 2020-01-30 16:24:34 +01:00
Naida Vatric
94ffc2d6d2 WIP Started saljic crawler. 2020-01-29 23:22:39 +01:00
Naida Vatric
b11f18696f Prepared config files. 2020-01-29 01:09:53 +01:00
Naida Vatric
fa46f75dd3 Changed default to switched on. 2020-01-28 22:28:52 +01:00
Naida Vatric
470f53d29b Changed to send email only to some users. 2020-01-24 16:04:28 +01:00
Naida Vatric
40509d2836 Changed logic for checkup.For testing. 2020-01-24 01:01:10 +01:00
Naida Vatric
b2c102bc1a Added check up email that everything works. 2020-01-23 15:48:48 +01:00
Naida Vatric
98263364c7 Added option to include-exclude ads without price 2020-01-23 11:13:53 +01:00
Naida Vatric
5b3491fdba Added migration and model change for searchReq table. 2020-01-23 10:12:56 +01:00
Naida Vatric
c6f0e039a5 Added price history log. 2020-01-21 23:12:04 +01:00
Naida Vatric
8d3f001678 WIP Added model, migration and bulk upsert fnc. 2020-01-21 16:28:47 +01:00
Naida Vatric
42eddb3aa5 WIP Bulk create not working. 2020-01-21 01:19:35 +01:00
Naida Vatric
0a181f742f WIP Added model and migration for new table priceHistory. 2020-01-20 23:09:02 +01:00
Naida Vatric
d117383802 Tested both ways for realestate and search req filters. 2020-01-17 22:58:22 +01:00
Naida Vatric
870b71a3c7 WIP Changed all logic for searchRequest. 2020-01-17 01:54:06 +01:00
Naida Vatric
e6725355a0 Changed sliders for different realestate types. 2020-01-15 01:33:37 +01:00
Naida Vatric
4fd4018bf6 Merge branch 'sliders-formating' of gitlab.com:saburly/marketalarm/web into add-even-more-filters 2020-01-14 23:19:51 +01:00
Naida Vatric
b9122f8f00 Added tag to email to denote staging from production. 2020-01-14 15:59:17 +01:00
Naida Vatric
fc33c1210a Add more detail to the email 2020-01-13 14:58:09 +01:00
Naida Vatric
511b290096 Login to prostor.ba befoure crawl. 2020-01-13 12:05:33 +01:00
Naida Vatric
ba43fa0713 WIP Changed cookies. 2020-01-13 11:02:26 +01:00
Naida Vatric
e70901d369 WIP Changed login to crawler. 2020-01-13 09:12:03 +01:00
Naida Vatric
8505282670 WiP Login of crawler prostor. 2020-01-12 01:22:50 +01:00
Naida Vatric
64e4835899 Changed redirecting for VIP ads. 2020-01-10 22:52:50 +01:00
Naida Vatric
1658325c4b WIP Fake vip ads. 2020-01-10 19:20:26 +01:00
Naida Vatric
49161c1b60 WIP Changed redirecting for VIP ads. 2020-01-09 12:19:19 +01:00
Naida Vatric
d23ddf849f Results title text made into link. 2020-01-07 01:06:22 +01:00
Naida Vatric
38bd0343f5 Merge branch 'results-link' of gitlab.com:saburly/marketalarm/web into no-all-results-email 2020-01-07 01:01:57 +01:00
Naida Vatric
259799144e Merge branch 'rental-crawler-fix' into 'master'
Rental crawler fix

See merge request saburly/marketalarm/web!80
2020-01-06 23:12:52 +00:00
Naida Vatric
bc73d4159d Merge branch 'master' into 'rental-crawler-fix'
# Conflicts:
#   .gitignore
2020-01-06 23:12:40 +00:00
Naida Vatric
37ad32fe76 Merge branch 'edit-location-start' into 'master'
Edit location start

See merge request saburly/marketalarm/web!79
2020-01-06 23:10:16 +00:00
Naida Vatric
94875a0fa3 Merge branch 'add-currency-to-price-filters' into 'master'
Add currency to price filters

See merge request saburly/marketalarm/web!78
2020-01-06 23:09:40 +00:00
Naida Vatric
fa4e0d64de Changed email content to show number of all matching real estates. 2020-01-06 23:59:56 +01:00
Naida Vatric
0c2d218d29 Changed floor numbers and basement-attic tag. 2020-01-02 00:10:31 +01:00
Naida Vatric
fed2dc00dc Changed number of rooms. 2019-12-29 23:42:39 +01:00
Naida Vatric
d5d3a1f306 Changed accesRoadType logic 2019-12-26 23:30:05 +01:00
Naida Vatric
42ff1f762f Changed to avoid falsy values and not defined realestate parametrs. 2019-12-21 02:20:26 +01:00
Naida Vatric
cc78e5acd5 Updated location to start from selected when edit. 2019-12-20 01:02:57 +01:00
Naida Vatric
55319a54e9 WIP Idea to implement bound map to be equal to selected 2019-12-19 02:12:23 +01:00
Naida Vatric
ef5de27c06 Add currency to price filters - added above input 2019-12-18 22:21:56 +01:00
Naida Vatric
bee390aa15 RealEstate included even is price is null. 2019-12-18 21:49:12 +01:00
Naida Vatric
251437f815 Changed searchRequest to include case of incomplete ads wanted. 2019-12-18 02:04:31 +01:00
Naida Vatric
4391aa5939 First review changes: applied prettier, ternary and changed accesRoadType filter 2019-12-17 11:28:00 +01:00
Naida Vatric
c672b3ab9f First review changes: applied prettier, ternary and changed accesRoadType filter 2019-12-17 11:18:58 +01:00
Bilal Catic
76f4ed0a30 apply prettier 2019-12-16 22:04:37 +01:00
Bilal Catic
73b3f0d22f Merge branch 'master' into 'add-even-more-filters'
# Conflicts:
#   app/config/appConfig.js
2019-12-16 20:52:40 +00:00
Bilal Catic
547411f189 Merge branch 'map-key-env-var' into 'master'
Map key env var

See merge request saburly/marketalarm/web!76
2019-12-16 20:49:58 +00:00
Bilal Catic
a45a0ec361 apply prettier 2019-12-16 21:40:21 +01:00
Naida Vatric
43074b6eb3 Finished map key to env 2019-12-16 21:40:21 +01:00
Naida Vatric
cb52c8592a Moved API Google Map key to env variables. 2019-12-16 21:40:21 +01:00
Naida Vatric
5a2fdb7291 Queries for db search changed. Needs testing. 2019-12-14 01:10:48 +01:00
Naida Vatric
e83712fb33 Changed acces road type check and include incomplete 2019-12-13 00:45:28 +01:00
Naida Vatric
0e585e74ae Merge branch 'add-even-more-filters' of gitlab.com:saburly/marketalarm/web into add-even-more-filters 2019-12-11 22:52:26 +01:00
Naida Vatric
e6e1688a49 Changed searchRequest helper 2019-12-11 22:44:26 +01:00
Naida Vatric
dee7c6000a WiP - changed db helpers 2019-12-11 01:24:18 +01:00
Naida Vatric
fbcda328b7 Merge branch 'update-dockerfile-readme-setup' into 'master'
Update docker file, readme and setup script

See merge request saburly/marketalarm/web!75
2019-12-10 20:44:53 +00:00
Naida Vatric
6f729b4135 "Taking over work in progress." 2019-12-10 11:07:31 +01:00
Naida Vatric
ef4fff4e70 Finished map key to env 2019-12-08 22:16:31 +01:00
Naida Vatric
ade28eb981 Moved API Google Map key to env variables. 2019-12-08 00:58:06 +01:00
Naida Vatric
5d792846ae Update docker file, readme and setup script 2019-12-05 22:46:50 +01:00
Bilal Catic
f8ea2f0f78 include new fields for search request 2019-11-18 22:46:18 +01:00
Bilal Catic
232221af9e improve css 2019-11-18 22:46:18 +01:00
Bilal Catic
271af35f0c add new enum value for access road type and heating type; add filter enums 2019-11-18 22:46:18 +01:00
Bilal Catic
6baa151ea2 add new fields to the search request table and model 2019-11-18 22:46:18 +01:00
Bilal Catic
e42531ff57 specify and use custom css class for checkbox labels 2019-11-18 22:46:18 +01:00
Bilal Catic
002a8e8572 add more filters to different tab on filters page; update css 2019-11-18 22:46:18 +01:00
Bilal Catic
fd8592c581 modify materialize tabs style to match Kivi color scheme 2019-11-18 22:46:18 +01:00
Bilal Catic
5cab9ee7c4 remove accordion files and import 2019-11-18 22:44:26 +01:00
Bilal Catic
1106f92560 add accordion for additional filters 2019-11-18 22:44:26 +01:00
Bilal Catic
ab8373651e update garage price slider options 2019-11-18 19:05:00 +01:00
Bilal Catic
ade09f6f15 change sale and rent action title 2019-11-18 18:56:34 +01:00
Bilal Catic
e4edc24cad Merge branch 'replace-front-page-next-button' into 'master'
select ad type on welcome page; update css

See merge request saburly/marketalarm/web!73
2019-11-18 14:49:07 +00:00
Bilal Catic
44565d2f89 select ad type on welcome page; update css 2019-11-18 10:48:41 +01:00
Bilal Catic
860014662a Merge branch 'add-more-real-estate-filters-to-crawler' into 'master'
Add more real estate filters to crawler

See merge request saburly/marketalarm/web!72
2019-11-14 13:58:57 +00:00
Bilal Catic
af42d2c448 improve OLX ad status detection 2019-11-14 08:47:48 +01:00
Bilal Catic
5148f88a62 improve Rental and Aktido ad status detection 2019-11-14 08:31:57 +01:00
Bilal Catic
a7cd75653d improve OLX ad status detection 2019-11-14 08:04:58 +01:00
Bilal Catic
168b2186e7 add more fields to the Prostor real estates crawler 2019-11-14 07:23:23 +01:00
Bilal Catic
1e68d640e2 add RENTED enum status 2019-11-14 07:22:54 +01:00
Bilal Catic
c13857bc09 add additional fields to the Prostor crawler 2019-11-14 02:09:42 +01:00
Bilal Catic
618dcd217e update ENV variables template file 2019-11-14 02:09:22 +01:00
Bilal Catic
3b3e2eda07 refactor Prostor crawler 2019-11-13 16:54:16 +01:00
Bilal Catic
ae93d2f03d update ENV variable description 2019-11-13 16:52:55 +01:00
Bilal Catic
a63671959b improve real estate properties detection for Rental 2019-11-12 22:53:16 +01:00
Bilal Catic
b6d68db3a3 improve real estate properties detection for aktido 2019-11-12 21:39:28 +01:00
Bilal Catic
c91e56c46e add additional real estate fields for Aktido crawler 2019-11-11 19:34:43 +01:00
Bilal Catic
e871550ba6 add two more heating types for Rental crawler 2019-11-11 18:46:01 +01:00
Bilal Catic
debdd01b28 add new fields to the Rental crawler 2019-11-11 17:15:46 +01:00
Bilal Catic
9e10800b02 add new heating type ENUM 2019-11-11 17:15:14 +01:00
Bilal Catic
cb9bb9e566 add rental scraper test script 2019-11-11 03:34:15 +01:00
Bilal Catic
b6024af2cb add new fields for OLX crawler 2019-11-08 17:05:51 +01:00
Bilal Catic
50514aaf03 add new ENUMS for real estate properties 2019-11-08 16:40:15 +01:00
Bilal Catic
9ba41dd7f7 add columns for update on duplicate real estate 2019-11-08 16:39:37 +01:00
Bilal Catic
02f5b97e80 add migration for new real estate fields; update real estate model 2019-11-08 16:27:55 +01:00
Bilal Catic
7242e233e3 Merge branch 'replace-frontend-arrow-functions-with-old-style-function' into 'master'
replace arrow functions on frontend with old style function

See merge request saburly/marketalarm/web!71
2019-11-08 13:11:56 +00:00
Bilal Catic
a77730cc5f replace arrow function with old style function 2019-11-08 14:07:00 +01:00
Bilal Catic
90db3025b5 delete obsolete range file 2019-11-08 14:06:22 +01:00
Bilal Catic
8a95409606 stop using spread operator 2019-11-08 14:03:04 +01:00
Bilal Catic
c2ffc906ea replace arrow functions on frontend with old style function 2019-11-08 13:52:55 +01:00
Bilal Catic
43747eb942 add deletedEmail field to the SearchRequest model 2019-11-05 18:33:31 +01:00
Bilal Catic
d07d0a3453 Merge branch 'move-unsubscribed-email-to-different-column' into 'master'
move email to different column on unsubscribe action

See merge request saburly/marketalarm/web!70
2019-11-05 13:20:41 +00:00
Bilal Catic
c87a1fc8a8 move email to different column on unsubscribe action 2019-11-05 14:19:56 +01:00
Bilal Catic
91cda0ff0f Merge branch 'prevent-sending-emails-to-unsubscribed-users' into 'master'
check if user is subscribed before sending email

See merge request saburly/marketalarm/web!69
2019-11-05 06:54:59 +00:00
Bilal Catic
310448dcb8 check if user is subscribed before sending email 2019-11-05 07:54:23 +01:00
Bilal Catic
8ea44f5fc7 Merge branch 'add-email-frequency-option' into 'master'
Add email frequency option

See merge request saburly/marketalarm/web!68
2019-11-04 13:28:44 +00:00
Bilal Catic
4d5571b1d8 improve email notification copy; add different copy for daily email 2019-11-04 14:28:11 +01:00
Bilal Catic
2be013de1f add npm script for sending daily notifications 2019-11-04 11:03:43 +01:00
Bilal Catic
23e319da5e add method for sending daily notifications 2019-11-04 11:02:26 +01:00
Bilal Catic
a120dfc4a3 add condition for sending emails based on email frequency property 2019-11-04 11:01:47 +01:00
Bilal Catic
5b2961d992 add db helper for searching not notified search request matches 2019-11-04 10:59:51 +01:00
Bilal Catic
b6bc67e442 add search request association to the Search Request Match model 2019-11-04 10:57:54 +01:00
Bilal Catic
46dbe40891 add missing ENV variables to env file template 2019-11-02 00:07:01 +01:00
Bilal Catic
7cc9550031 save/load selected email frequency on query review step 2019-11-01 19:21:37 +01:00
Bilal Catic
1117592f4c add migration and update model to include email frequency in search req 2019-11-01 11:43:08 +01:00
Bilal Catic
a0449f7ffd implement segmented select control without JS 2019-11-01 08:53:50 +01:00
Bilal Catic
e3e0ddd508 stop logging scrape action for Rental crawler 2019-11-01 01:02:45 +01:00
Bilal Catic
2e3ddbac95 fix request ad type bug 2019-11-01 00:01:02 +01:00
Bilal Catic
5433a71859 Merge branch 'add-padding-for-page-content' into 'master'
Add padding for page content

See merge request saburly/marketalarm/web!67
2019-10-31 18:17:25 +00:00
Bilal Catic
6aff0d221b move query review table from top 2019-10-31 19:16:57 +01:00
64 changed files with 5402 additions and 724 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
node_modules/ node_modules/
.env .env
.idea/ .idea/
.eslintrc
.vscode/

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
*.ejs

View File

@@ -3,6 +3,7 @@ FROM postgres:11.3
ENV POSTGIS_MAJOR 2.4 ENV POSTGIS_MAJOR 2.4
RUN apt-get update \ RUN apt-get update \
&& apt-get --assume-yes install postgresql-11-postgis-2.5-scripts\
&& apt-get --assume-yes install software-properties-common postgis\ && apt-get --assume-yes install software-properties-common postgis\
&& rm -rf /var/lib/apt/lists/ && rm -rf /var/lib/apt/lists/

View File

@@ -4,6 +4,8 @@ The purpose of this project is to build a web application that enables subscribi
## Setup ## Setup
* Before setup please confirm that Docker is installed `docker --version`. If not install it from official site.
### Setup with npm commands ### Setup with npm commands
1. Install packages 1. Install packages
@@ -24,7 +26,7 @@ this will create and run postgres image and then execute migrations
`docker build -t marketalerts .` `docker build -t marketalerts .`
2. Run postgres image with 2. Run postgres image with
`docker run --name pg_marketalerts -d -p 5432:5432 marketalerts` `docker run -e POSTGRES_USER=docker -e POSTGRES_PASSWORD=docker -e POSTGRES_DB=marketalerts --name pg_marketalerts -d -p 5432:5432 marketalerts`
3. Install packages 3. Install packages
`npm install` `npm install`
@@ -41,3 +43,4 @@ this will create and run postgres image and then execute migrations
- AWS SES credentials are handled with env vratiables - AWS SES credentials are handled with env vratiables
- Notification emails are sent in batches of 50, by using SES templates - Notification emails are sent in batches of 50, by using SES templates
- Make sure that you are using different templates for different envirorments - Make sure that you are using different templates for different envirorments

View File

@@ -7,7 +7,42 @@ const PRICE_SLIDER_OPTIONS_SALE = {
step: 1000, step: 1000,
connect: true connect: true
}; };
const FLAT_PRICE_SLIDER_OPTIONS_SALE = {
start: [50000, 150000],
range: {
min: [0],
max: [800000]
},
step: 5000,
connect: true
};
const HOUSE_PRICE_SLIDER_OPTIONS_SALE = {
start: [50000, 150000],
range: {
min: [0],
max: [1500000]
},
step: 10000,
connect: true
};
const OFFICE_PRICE_SLIDER_OPTIONS_SALE = {
start: [15000, 50000],
range: {
min: [0],
max: [2000000]
},
step: 2000,
connect: true
};
const LAND_PRICE_SLIDER_OPTIONS_SALE = {
start: [40000, 80000],
range: {
min: [0],
max: [2000000]
},
step: 10000,
connect: true
};
const PRICE_SLIDER_OPTIONS_RENT = { const PRICE_SLIDER_OPTIONS_RENT = {
start: [300, 500], start: [300, 500],
range: { range: {
@@ -17,18 +52,62 @@ const PRICE_SLIDER_OPTIONS_RENT = {
step: 50, step: 50,
connect: true connect: true
}; };
const FLAT_PRICE_SLIDER_OPTIONS_RENT = {
start: [300, 600],
range: {
min: [0],
max: [4000]
},
step: 100,
connect: true
};
const HOUSE_PRICE_SLIDER_OPTIONS_RENT = {
start: [500, 1000],
range: {
min: [0],
max: [10000]
},
step: 100,
connect: true
};
const OFFICE_PRICE_SLIDER_OPTIONS_RENT = {
start: [200, 1000],
range: {
min: [0],
max: [20000]
},
step: 100,
connect: true
};
const LAND_PRICE_SLIDER_OPTIONS_RENT = {
start: [500, 1000],
range: {
min: [0],
max: [20000]
},
step: 100,
connect: true
};
//This will be used for Flats, Apartments, Houses //This will be used for Flats, Apartments, Houses
const HOME_SIZE_SLIDER_OPTIONS = { const HOME_SIZE_SLIDER_OPTIONS = {
start: [30, 75], start: [30, 75],
range: { range: {
min: [0], min: [0],
max: [400] max: [500]
}, },
step: 5, step: 5,
connect: true connect: true
}; };
const OFFICE_SIZE_SLIDER_OPTIONS = {
start: [30, 150],
range: {
min: [0],
max: [1200]
},
step: 10,
connect: true
};
const GARDEN_SIZE_SLIDER_OPTIONS = { const GARDEN_SIZE_SLIDER_OPTIONS = {
start: [100, 1000], start: [100, 1000],
range: { range: {
@@ -58,13 +137,23 @@ const GARAGE_SIZE_SLIDER_OPTIONS = {
connect: true connect: true
}; };
const GARAGE_PRICE_SLIDER_OPTIONS = { const GARAGE_PRICE_SLIDER_OPTIONS_SALE = {
start: [2000, 10000], start: [2000, 10000],
range: { range: {
min: [0], min: [0],
max: [100000] max: [60000]
}, },
step: 500, step: 200,
connect: true
};
const GARAGE_PRICE_SLIDER_OPTIONS_RENT = {
start: [50, 150],
range: {
min: [0],
max: [1000]
},
step: 10,
connect: true connect: true
}; };
@@ -72,12 +161,12 @@ const AD_TYPE = {
AD_TYPE_SALE: { AD_TYPE_SALE: {
id: 1, id: 1,
stringId: "SALE", stringId: "SALE",
title: "Prodaja" title: "Kupi"
}, },
AD_TYPE_RENT: { AD_TYPE_RENT: {
id: 2, id: 2,
stringId: "RENT", stringId: "RENT",
title: "Najam" title: "Unajmi"
}, },
AD_TYPE_REQUEST: { AD_TYPE_REQUEST: {
id: 3, id: 3,
@@ -94,16 +183,30 @@ const AD_CATEGORY = {
id: "FLAT", id: "FLAT",
title: "Stan", title: "Stan",
hasGardenSize: false, hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, hasAccesRoadType: true,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, hasBalconyProp: true,
hasNewBuildingProp: true,
hasElevatorProp: true,
hasNumberOfRoom: true,
hasNumberOfFloors: false,
hasFloorProp: true,
priceSliderOptionsSale: FLAT_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: FLAT_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
}, },
HOUSE: { HOUSE: {
id: "HOUSE", id: "HOUSE",
title: "Kuća", title: "Kuća",
hasGardenSize: true, hasGardenSize: true,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, hasAccesRoadType: true,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, hasBalconyProp: true,
hasNewBuildingProp: true,
hasElevatorProp: false,
hasNumberOfRoom: true,
hasNumberOfFloors: true,
hasFloorProp: false,
priceSliderOptionsSale: HOUSE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: HOUSE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS, sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
}, },
@@ -111,40 +214,75 @@ const AD_CATEGORY = {
id: "OFFICE", id: "OFFICE",
title: "Kancelarija", title: "Kancelarija",
hasGardenSize: false, hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, hasAccesRoadType: true,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, hasBalconyProp: false,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS hasNewBuildingProp: true,
hasElevatorProp: true,
hasNumberOfRoom: true,
hasNumberOfFloors: false,
hasFloorProp: true,
priceSliderOptionsSale: OFFICE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: OFFICE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: OFFICE_SIZE_SLIDER_OPTIONS
}, },
LAND: { LAND: {
id: "LAND", id: "LAND",
title: "Zemljište", title: "Zemljište",
hasGardenSize: false, hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, hasAccesRoadType: true,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, hasBalconyProp: false,
hasNewBuildingProp: false,
hasElevatorProp: false,
hasNumberOfRoom: false,
hasNumberOfFloors: false,
hasFloorProp: false,
priceSliderOptionsSale: LAND_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: LAND_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: LAND_SIZE_SLIDER_OPTIONS sizeSliderOptions: LAND_SIZE_SLIDER_OPTIONS
}, },
APARTMENT: { APARTMENT: {
id: "APARTMENT", id: "APARTMENT",
title: "Apartman", title: "Apartman",
hasGardenSize: false, hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, hasAccesRoadType: true,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, hasBalconyProp: true,
hasNewBuildingProp: true,
hasElevatorProp: true,
hasNumberOfRoom: true,
hasNumberOfFloors: false,
hasFloorProp: true,
priceSliderOptionsSale: FLAT_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: FLAT_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
}, },
GARAGE: { GARAGE: {
id: "GARAGE", id: "GARAGE",
title: "Garaža", title: "Garaža",
hasGardenSize: false, hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, hasAccesRoadType: true,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, hasBalconyProp: false,
hasNewBuildingProp: false,
hasElevatorProp: false,
hasNumberOfRoom: false,
hasNumberOfFloors: false,
hasFloorProp: false,
priceSliderOptionsSale: GARAGE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: GARAGE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: GARAGE_SIZE_SLIDER_OPTIONS sizeSliderOptions: GARAGE_SIZE_SLIDER_OPTIONS
}, },
COTTAGE: { COTTAGE: {
id: "COTTAGE", id: "COTTAGE",
title: "Vikendica", title: "Vikendica",
hasGardenSize: true, hasGardenSize: true,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, hasAccesRoadType: true,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, hasBalconyProp: true,
hasNewBuildingProp: true,
hasElevatorProp: false,
hasNumberOfRoom: true,
hasNumberOfFloors: true,
hasFloorProp: false,
priceSliderOptionsSale: HOUSE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: HOUSE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS, sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
} }
@@ -156,14 +294,17 @@ const AD_STATUS = {
STATUS_SOLD: 3, STATUS_SOLD: 3,
STATUS_DELETED: 4, STATUS_DELETED: 4,
STATUS_URGENT: 5, STATUS_URGENT: 5,
STATUS_DISCOUNTED: 6 STATUS_DISCOUNTED: 6,
STATUS_RENTED: 7,
STATUS_VIP: 8
}; };
const AD_AGENCY = { const AD_AGENCY = {
OLX: "OLX", OLX: "OLX",
RENTAL: "RENTAL", RENTAL: "RENTAL",
PROSTOR: "PROSTOR", PROSTOR: "PROSTOR",
AKTIDO: "AKTIDO" AKTIDO: "AKTIDO",
SALJIC: "SALJIC"
}; };
const CRAWLER_AD_TYPE = { const CRAWLER_AD_TYPE = {
@@ -174,10 +315,108 @@ const CRAWLER_AD_TYPE = {
ONLY_REQUEST: 4 ONLY_REQUEST: 4
}; };
const EMAIL_FREQUENCY = {
ASAP: {
id: 1,
stringId: "ASAP",
title: "Odmah"
},
DAILY: {
id: 2,
stringId: "DAILY",
title: "Jednom dnevno"
}
};
const HEATING_TYPE = {
ANY: {
id: "ANY",
title: "Svi"
},
NO_HEATING: {
id: "NO_HEATING",
title: "Nije uvedeno"
},
ELECTRICITY: {
id: "ELECTRICITY",
title: "Struja"
},
GAS: {
id: "GAS",
title: "Plin"
},
WOOD: {
id: "WOOD",
title: "Drva"
},
CENTRAL_CITY: {
id: "CENTRAL_CITY",
title: "Centralno (gradsko)"
},
CENTRAL_BOILER: {
id: "CENTRAL_BOILER",
title: "Centralno (kotlovnica)"
},
CENTRAL_GAS: {
id: "CENTRAL_GAS",
title: "Centralno (plin)"
},
HEAT_PUMP: {
id: "HEAT_PUMP",
title: "Toplotna pumpa"
},
OTHER: {
id: "OTHER",
title: "Drugo"
}
};
const ACCESS_ROAD_TYPE = {
ANY: {
id: "ANY",
title: "Svi"
},
ASPHALT: {
id: "ASPHALT",
title: "Asfalt"
},
CONCRETE: {
id: "CONCRETE",
title: "Beton"
},
MACADAM: {
id: "MACADAM",
title: "Makadam"
},
OTHER: {
id: "OTHER",
title: "Drugo"
}
};
const FURNISHING_TYPE = {
NOT_FURNISHED: {
id: "NOT_FURNISHED",
title: "Nenamješten"
},
HALF_FURNISHED: {
id: "HALF_FURNISHED",
title: "Polunamješten"
},
FURNISHED: {
id: "FURNISHED",
title: "Namješten"
}
};
module.exports = { module.exports = {
AD_TYPE, AD_TYPE,
AD_CATEGORY, AD_CATEGORY,
AD_STATUS, AD_STATUS,
AD_AGENCY, AD_AGENCY,
CRAWLER_AD_TYPE CRAWLER_AD_TYPE,
EMAIL_FREQUENCY,
HEATING_TYPE,
ACCESS_ROAD_TYPE,
FURNISHING_TYPE
}; };

110
app/common/filterEnums.js Normal file
View File

@@ -0,0 +1,110 @@
const { AD_CATEGORY, ACCESS_ROAD_TYPE, HEATING_TYPE } = require("./enums");
const ADVANCED_BOOLEAN_FILTERS = [
{
dbField: "balcony",
title: "Balkon",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE
]
},
{
dbField: "elevator",
title: "Lift",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.OFFICE
]
},
{
dbField: "newBuilding",
title: "Novogradnja",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
}
];
const ADVANCED_SEGMENT_SELECT_FILTERS = [
{
dbField: "accessRoadType",
title: "Pristupni put",
values: Object.keys(ACCESS_ROAD_TYPE).map(key => ACCESS_ROAD_TYPE[key]),
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
]
}
// {
// dbField: "heatingType",
// title: "Vrsta grijanja",
// values: Object.keys(HEATING_TYPE).map(key => HEATING_TYPE[key]),
// categoriesToShow: [
// AD_CATEGORY.FLAT,
// AD_CATEGORY.HOUSE,
// AD_CATEGORY.APARTMENT,
// AD_CATEGORY.COTTAGE,
// AD_CATEGORY.OFFICE
// ]
// }
];
const ADVANCED_RANGE_FILTERS = [
{
id: "numberOfFloors",
title: "Broj spratova",
dbFieldMin: "numberOfFloorsMin",
dbFieldMax: "numberOfFloorsMax",
validValueMin: -1,
validValueMax: 50,
categoriesToShow: [AD_CATEGORY.HOUSE, AD_CATEGORY.COTTAGE]
},
{
id: "floor",
title: "Sprat",
dbFieldMin: "floorMin",
dbFieldMax: "floorMax",
validValueMin: -10,
validValueMax: 50,
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.OFFICE
]
},
{
id: "numberOfRooms",
title: "Broj soba",
dbFieldMin: "numberOfRoomsMin",
dbFieldMax: "numberOfRoomsMax",
decimalPlaces: 1,
validValueMin: 0,
validValueMax: 200,
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
}
];
module.exports = {
ADVANCED_BOOLEAN_FILTERS,
ADVANCED_SEGMENT_SELECT_FILTERS,
ADVANCED_RANGE_FILTERS
};

View File

@@ -9,11 +9,15 @@ const APP_URL =
? process.env.APP_URL || "http://market-alarm" ? process.env.APP_URL || "http://market-alarm"
: process.env.APP_URL || `${APP_BASE_URL}:${APP_PORT}`; : process.env.APP_URL || `${APP_BASE_URL}:${APP_PORT}`;
const STAGING = process.env.ENVIRONMENT !== "production";
const DEFAULT_TIMEZONE = "Europe/Sarajevo"; const DEFAULT_TIMEZONE = "Europe/Sarajevo";
const CRAWLER_INTERVAL = parseInt(process.env.CRAWLER_INTERVAL) || 60; const CRAWLER_INTERVAL = parseInt(process.env.CRAWLER_INTERVAL) || 60;
const STOP_CRAWLER = !!parseInt(process.env.STOP_CRAWLER); const STOP_CRAWLER = !!parseInt(process.env.STOP_CRAWLER);
const CHECK_UP_DAYS = parseInt(process.env.CHECK_UP_DAYS) || 10;
const AWS_EMAIL_CONFIG = { const AWS_EMAIL_CONFIG = {
REGION: process.env.AWS_REGION || "", REGION: process.env.AWS_REGION || "",
CREDENTIALS: { CREDENTIALS: {
@@ -30,6 +34,20 @@ const MAX_REAL_ESTATES_IN_FIRST_EMAIL =
const PRINT_CRAWLER_DEBUG = process.env.PRINT_CRAWLER_DEBUG_INFO || 0; const PRINT_CRAWLER_DEBUG = process.env.PRINT_CRAWLER_DEBUG_INFO || 0;
const API_MAP_KEY = process.env.API_MAP_KEY || "";
const PROSTOR_LOGIN = {
EMAIL: process.env.PROSTOR_LOGIN_EMAIL,
PASSWORD: process.env.PROSTOR_LOGIN_PASS
};
const USER_AGENT =
process.env.USER_AGENT ||
"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36";
const USE_SCRAPER_API = process.env.USE_SCRAPER_API || 1; //Default to use
const SCRAPER_API_KEY = process.env.SCRAPER_API_KEY || "";
module.exports = { module.exports = {
APP_PORT, APP_PORT,
APP_URL, APP_URL,
@@ -39,5 +57,12 @@ module.exports = {
AWS_EMAIL_CONFIG, AWS_EMAIL_CONFIG,
MAX_REAL_ESTATES_IN_EMAIL, MAX_REAL_ESTATES_IN_EMAIL,
MAX_REAL_ESTATES_IN_FIRST_EMAIL, MAX_REAL_ESTATES_IN_FIRST_EMAIL,
PRINT_CRAWLER_DEBUG PRINT_CRAWLER_DEBUG,
API_MAP_KEY,
STAGING,
CHECK_UP_DAYS,
PROSTOR_LOGIN,
USER_AGENT,
USE_SCRAPER_API,
SCRAPER_API_KEY
}; };

View File

@@ -4,9 +4,37 @@ const getLocation = async (req, res) => {
const title = "Odaberite lokaciju"; const title = "Odaberite lokaciju";
const nextStep = req.query.nextStep || "/"; const nextStep = req.query.nextStep || "/";
//Check if location data already exists (active request)
//If it does then get location is called through edit field query
//and map should show already selected location not initial map
let selectedLatLngBounds = {};
let boundsSelected = false;
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
const selectedArea = searchRequest.areaToSearch;
const sw = selectedArea.coordinates[0][3];
const ne = selectedArea.coordinates[0][1];
if (sw[0] && ne[0]) {
selectedLatLngBounds = {
swLat: sw[1],
swLng: sw[0],
neLat: ne[1],
neLng: ne[0]
};
boundsSelected = true;
}
res.render("location", { res.render("location", {
nextStep, nextStep,
title title,
boundsSelected,
selectedLatLngBounds
}); });
}; };

View File

@@ -3,9 +3,9 @@ const { isValidEmail } = require("../helpers/email");
const { const {
notifyForNewSearchRequest notifyForNewSearchRequest
} = require("../services/notificationService"); } = require("../services/notificationService");
const { AD_CATEGORY, AD_TYPE } = require("../common/enums"); const { AD_CATEGORY, AD_TYPE, EMAIL_FREQUENCY } = require("../common/enums");
const getQueryReviewData = searchRequest => { const getQueryReviewTableData = searchRequest => {
const { const {
id, id,
adType, adType,
@@ -87,15 +87,26 @@ const getQueryReview = async (req, res) => {
const title = "Da li je ovo to što ste tražili ?"; const title = "Da li je ovo to što ste tražili ?";
const nextStep = req.query.nextStep; const nextStep = req.query.nextStep;
const error = req.query.error; const error = req.query.error;
const queryReviewData = getQueryReviewData(searchRequest); const queryReviewTableData = getQueryReviewTableData(searchRequest);
const email = searchRequest.email; const email = searchRequest.email;
let selectedEmailFrequency;
switch (searchRequest.emailFrequency) {
case EMAIL_FREQUENCY.ASAP.stringId:
selectedEmailFrequency = EMAIL_FREQUENCY.ASAP.id;
break;
case EMAIL_FREQUENCY.DAILY.stringId:
selectedEmailFrequency = EMAIL_FREQUENCY.DAILY.id;
break;
}
res.render("queryReview", { res.render("queryReview", {
nextStep, nextStep,
queryReviewData, queryReviewTableData,
title, title,
email, email,
error selectedEmailFrequency,
error,
EMAIL_FREQUENCY
}); });
}; };
@@ -107,17 +118,26 @@ const postQueryReview = async (req, res) => {
} }
const nextStep = req.query.nextStep || "/ponovo"; const nextStep = req.query.nextStep || "/ponovo";
const emailFrequency =
parseInt(req.body.emailFrequency) || EMAIL_FREQUENCY.ASAP.id;
const emailInput = req.body.email; const emailInput = req.body.email;
const emailConfirmInput = req.body.confirmEmail; const emailConfirmInput = req.body.confirmEmail;
const title = "Da li je ovo to što ste tražili ?"; const title = "Da li je ovo to što ste tražili ?";
const queryReviewData = getQueryReviewData(searchRequest); const queryReviewTableData = getQueryReviewTableData(searchRequest);
let emailFrequencyStringId = EMAIL_FREQUENCY.ASAP.stringId;
if (emailFrequency === EMAIL_FREQUENCY.DAILY.id) {
emailFrequencyStringId = EMAIL_FREQUENCY.DAILY.stringId;
}
searchRequest.emailFrequency = emailFrequencyStringId;
if (emailInput !== emailConfirmInput) { if (emailInput !== emailConfirmInput) {
const error = "Greška ! Unešeni emailovi nisu isti"; const error = "Greška ! Unešeni emailovi nisu isti";
res.render("queryReview", { res.render("queryReview", {
error, error,
title, title,
queryReviewData, queryReviewTableData,
email: "" email: ""
}); });
return; return;
@@ -128,7 +148,7 @@ const postQueryReview = async (req, res) => {
res.render("queryReview", { res.render("queryReview", {
error, error,
title, title,
queryReviewData, queryReviewTableData,
email: "" email: ""
}); });
return; return;
@@ -147,7 +167,7 @@ const postQueryReview = async (req, res) => {
res.render("queryReview", { res.render("queryReview", {
error, error,
title, title,
queryReviewData, queryReviewTableData,
email: "" email: ""
}); });
return; return;
@@ -164,7 +184,7 @@ const postQueryReview = async (req, res) => {
res.render("queryReview", { res.render("queryReview", {
error, error,
title, title,
queryReviewData, queryReviewTableData,
email: "" email: ""
}); });
return; return;

View File

@@ -1,5 +1,10 @@
const { currentSearchRequest } = require("../helpers/url"); const { currentSearchRequest } = require("../helpers/url");
const { AD_CATEGORY, AD_TYPE } = require("../common/enums"); const { AD_CATEGORY, AD_TYPE, ACCESS_ROAD_TYPE } = require("../common/enums");
const {
ADVANCED_BOOLEAN_FILTERS,
ADVANCED_SEGMENT_SELECT_FILTERS,
ADVANCED_RANGE_FILTERS
} = require("../common/filterEnums");
const getFilters = async (req, res) => { const getFilters = async (req, res) => {
const searchRequest = await currentSearchRequest(req); const searchRequest = await currentSearchRequest(req);
@@ -19,7 +24,19 @@ const getFilters = async (req, res) => {
sizeMin, sizeMin,
sizeMax, sizeMax,
gardenSizeMin, gardenSizeMin,
gardenSizeMax gardenSizeMax,
numberOfRoomsMin,
numberOfRoomsMax,
numberOfFloorsMin,
numberOfFloorsMax,
floorMin,
floorMax,
includeIncompleteAds,
balcony,
elevator,
newBuilding,
accessRoadType,
includeWithoutPrice
} = searchRequest; } = searchRequest;
const category = AD_CATEGORY[realEstateType] || AD_CATEGORY.FLAT; const category = AD_CATEGORY[realEstateType] || AD_CATEGORY.FLAT;
@@ -41,6 +58,40 @@ const getFilters = async (req, res) => {
return; return;
} }
// TODO: Maybe this is slow, pay attention to this
const filterFilters = filterObject => {
const filterCategories = filterObject.categoriesToShow;
return filterCategories.indexOf(category) !== -1;
};
const advancedBooleanFilterObjects = ADVANCED_BOOLEAN_FILTERS.filter(
filterFilters
);
const advancedSegmentSelectFilterObjects = ADVANCED_SEGMENT_SELECT_FILTERS.filter(
filterFilters
);
const advancedRangeFilterObjects = ADVANCED_RANGE_FILTERS.filter(
filterFilters
);
const advancedBooleanFilterValues = {
includeIncompleteAds,
balcony,
elevator,
newBuilding
};
const advancedSegmentSelectFilterValues = {
accessRoadType
};
const advancedRangeFilterValues = {
numberOfFloorsMin,
numberOfFloorsMax,
numberOfRoomsMin,
numberOfRoomsMax,
floorMin,
floorMax
};
if (priceMin || priceMax) { if (priceMin || priceMax) {
priceSliderOptions.start = [priceMin, priceMax]; priceSliderOptions.start = [priceMin, priceMax];
} }
@@ -58,7 +109,15 @@ const getFilters = async (req, res) => {
hasGardenSize, hasGardenSize,
priceSliderOptions: JSON.stringify(priceSliderOptions), priceSliderOptions: JSON.stringify(priceSliderOptions),
sizeSliderOptions: JSON.stringify(sizeSliderOptions), sizeSliderOptions: JSON.stringify(sizeSliderOptions),
gardenSizeSliderOptions: JSON.stringify(gardenSizeSliderOptions) gardenSizeSliderOptions: JSON.stringify(gardenSizeSliderOptions),
advancedBooleanFilterObjects,
advancedBooleanFilterValues,
advancedSegmentSelectFilterObjects,
advancedSegmentSelectFilterValues,
advancedRangeFilterObjects,
advancedRangeFilterValues,
includeIncompleteAds,
includeWithoutPrice
}); });
}; };
@@ -78,13 +137,93 @@ const postFilters = async (req, res) => {
const sizeMin = parseInt(req.body.sizeMin) || 0; const sizeMin = parseInt(req.body.sizeMin) || 0;
const sizeMax = parseInt(req.body.sizeMax) || 0; const sizeMax = parseInt(req.body.sizeMax) || 0;
//TODO: Filter validation const advancedRangeFilters = {};
ADVANCED_RANGE_FILTERS.forEach(filter => {
let parsingFunction = parseInt;
if (filter.decimalPlaces) {
parsingFunction = parseFloat;
}
advancedRangeFilters[filter.dbFieldMin] = parsingFunction(
req.body[filter.dbFieldMin]
);
advancedRangeFilters[filter.dbFieldMax] = parsingFunction(
req.body[filter.dbFieldMax]
);
advancedRangeFilters[filter.dbFieldMin] = isNaN(
advancedRangeFilters[filter.dbFieldMin]
)
? null
: advancedRangeFilters[filter.dbFieldMin];
advancedRangeFilters[filter.dbFieldMax] = isNaN(
advancedRangeFilters[filter.dbFieldMax]
)
? null
: advancedRangeFilters[filter.dbFieldMax];
try {
if (filter.decimalPlaces) {
advancedRangeFilters[filter.dbFieldMin] = advancedRangeFilters[
filter.dbFieldMin
].toFixed(filter.decimalPlaces);
advancedRangeFilters[filter.dbFieldMax] = advancedRangeFilters[
filter.dbFieldMax
].toFixed(filter.decimalPlaces);
}
} catch (e) {
advancedRangeFilters[filter.dbFieldMin] = null;
advancedRangeFilters[filter.dbFieldMax] = null;
}
if (
advancedRangeFilters[filter.dbFieldMin] < filter.validValueMin ||
advancedRangeFilters[filter.dbFieldMin] > filter.validValueMax
) {
advancedRangeFilters[filter.dbFieldMin] = filter.validValueMin;
}
if (
advancedRangeFilters[filter.dbFieldMax] < filter.validValueMin ||
advancedRangeFilters[filter.dbFieldMax] > filter.validValueMax
) {
advancedRangeFilters[filter.dbFieldMax] = filter.validValueMax;
}
});
const includeIncompleteAds = req.body.includeIncompleteAds === "on";
const includeWithoutPrice = req.body.includeWithoutPrice === "on";
const balcony = req.body.balcony === "on";
const elevator = req.body.elevator === "on";
const newBuilding = req.body.newBuilding === "on";
const accessRoadType = req.body.accessRoadType;
if (!ACCESS_ROAD_TYPE[accessRoadType]) {
res.render("notFound", { title: " Greška !" });
return;
}
//TODO: Filter validation
searchRequest.priceMin = priceMin; searchRequest.priceMin = priceMin;
searchRequest.priceMax = priceMax; searchRequest.priceMax = priceMax;
searchRequest.sizeMin = sizeMin; searchRequest.sizeMin = sizeMin;
searchRequest.sizeMax = sizeMax; searchRequest.sizeMax = sizeMax;
for (const filter of Object.keys(advancedRangeFilters)) {
searchRequest[filter] = advancedRangeFilters[filter];
}
searchRequest.balcony = balcony;
searchRequest.elevator = elevator;
searchRequest.newBuilding = newBuilding;
searchRequest.includeIncompleteAds = includeIncompleteAds;
searchRequest.includeWithoutPrice = includeWithoutPrice;
searchRequest.accessRoadType = accessRoadType;
if ( if (
req.body.gardenSizeMin !== undefined && req.body.gardenSizeMin !== undefined &&
req.body.gardenSizeMax !== undefined req.body.gardenSizeMax !== undefined
@@ -97,7 +236,6 @@ const postFilters = async (req, res) => {
searchRequest.gardenSizeMin = gardenSizeMin; searchRequest.gardenSizeMin = gardenSizeMin;
searchRequest.gardenSizeMax = gardenSizeMax; searchRequest.gardenSizeMax = gardenSizeMax;
} }
await searchRequest.save(); await searchRequest.save();
res.redirect(nextStepUrl); res.redirect(nextStepUrl);

View File

@@ -2,13 +2,14 @@
const { const {
findRealEstatesForSearchRequest findRealEstatesForSearchRequest
} = require("../helpers/db/searchRequestMatch"); } = require("../helpers/db/searchRequestMatch");
const { AD_STATUS } = require("../common/enums");
const getRealEstates = async (req, res) => { const getRealEstates = async (req, res) => {
const searchRequestId = req.params["searchRequestId"] || ""; const searchRequestId = req.params["searchRequestId"] || "";
const realEstates = await findRealEstatesForSearchRequest(searchRequestId); const realEstates = await findRealEstatesForSearchRequest(searchRequestId);
const title = "Nekretnine koje odgovaraju Vašim uslovima pretrage"; const title = "Nekretnine koje odgovaraju Vašim uslovima pretrage";
res.render("realEstates", { realEstates, title }); res.render("realEstates", { realEstates, title, AD_STATUS });
}; };
module.exports = { module.exports = {

View File

@@ -1,9 +1,11 @@
const { getRealEstateById } = require("../helpers/db/realEstate"); const { getRealEstateById } = require("../helpers/db/realEstate");
const { AD_STATUS } = require("../common/enums");
const getRedirect = async (req, res) => { const getRedirect = async (req, res) => {
const id = req.params.id || null; const id = req.params.id || null;
let error = false; let error = false;
let redirectUrl = undefined; let redirectUrl = undefined;
let vipAd = undefined;
if (!id) { if (!id) {
error = true; error = true;
} else { } else {
@@ -13,6 +15,7 @@ const getRedirect = async (req, res) => {
error = true; error = true;
} else { } else {
redirectUrl = realEstate.url; redirectUrl = realEstate.url;
vipAd = realEstate.adStatus === AD_STATUS.STATUS_VIP;
} }
} catch (e) { } catch (e) {
error = true; error = true;
@@ -24,7 +27,7 @@ const getRedirect = async (req, res) => {
res.render("notFound", { title }); res.render("notFound", { title });
} else { } else {
const title = "Preusmjeravanje"; const title = "Preusmjeravanje";
res.render("redirect", { title, redirectUrl }); res.render("redirect", { title, redirectUrl, vipAd });
} }
}; };

View File

@@ -10,6 +10,8 @@ const getUnsubscribe = async (req, res) => {
} }
searchRequest.subscribed = false; searchRequest.subscribed = false;
searchRequest.deletedEmail = searchRequest.email;
searchRequest.email = "";
await searchRequest.save(); await searchRequest.save();
res.render("unsubscribe", { nextStep: "/vrstanekretnine", title }); res.render("unsubscribe", { nextStep: "/vrstanekretnine", title });

View File

@@ -1,7 +1,42 @@
const { createSearchRequest } = require("../helpers/db/searchRequest");
const { AD_TYPE, AD_CATEGORY } = require("../common/enums");
const getWelcome = (req, res) => { const getWelcome = (req, res) => {
res.render("welcome", { nextStep: "/vrstanekretnine", title: false }); res.render("welcome", {
title: false,
AD_TYPE
});
};
const postWelcome = async (req, res) => {
const adType = parseInt(req.body.adType);
const adTypeStringIds = {
[AD_TYPE.AD_TYPE_SALE.id]: AD_TYPE.AD_TYPE_SALE.stringId,
[AD_TYPE.AD_TYPE_RENT.id]: AD_TYPE.AD_TYPE_RENT.stringId
};
const adTypeStringId =
adTypeStringIds[adType] || AD_TYPE.AD_TYPE_SALE.stringId;
let nextStepUrl = "";
try {
const newSearchRequest = await createSearchRequest({
adType: adTypeStringId,
realEstateType: AD_CATEGORY.FLAT.id
});
nextStepUrl = `/vrstanekretnine/${newSearchRequest.id}`;
} catch (error) {
console.log(error);
nextStepUrl = `/`;
}
res.redirect(nextStepUrl);
}; };
module.exports = { module.exports = {
getWelcome getWelcome,
postWelcome
}; };

View File

@@ -9,12 +9,14 @@ const OlxCrawler = require("./specificCrawlers/olx");
const RentalCrawler = require("./specificCrawlers/rental"); const RentalCrawler = require("./specificCrawlers/rental");
const ProstorCrawler = require("./specificCrawlers/prostor"); const ProstorCrawler = require("./specificCrawlers/prostor");
const AktidoCrawler = require("./specificCrawlers/aktido"); const AktidoCrawler = require("./specificCrawlers/aktido");
const SaljicCrawler = require("./specificCrawlers/saljic");
const { const {
OLX_CONFIG, OLX_CONFIG,
RENTAL_CONFIG, RENTAL_CONFIG,
PROSTOR_CONFIG, PROSTOR_CONFIG,
AKTIDO_CONFIG AKTIDO_CONFIG,
SALJIC_CONFIG
} = require("./crawlerConfig"); } = require("./crawlerConfig");
const PostgresSaver = require("./savers/postgres"); const PostgresSaver = require("./savers/postgres");
@@ -57,6 +59,15 @@ async function crawlAll() {
AKTIDO_CONFIG.AKTIDO_MAX_RESULTS_PER_PAGE, AKTIDO_CONFIG.AKTIDO_MAX_RESULTS_PER_PAGE,
AKTIDO_CONFIG.AKTIDO_IGNORED_USERNAMES, AKTIDO_CONFIG.AKTIDO_IGNORED_USERNAMES,
AKTIDO_CONFIG.AKTIDO_DELAY_BETWEEN_PAGES AKTIDO_CONFIG.AKTIDO_DELAY_BETWEEN_PAGES
),
new SaljicCrawler(
[postgresSaver],
SALJIC_CONFIG.SALJIC_CRAWLER_AD_TYPE,
SALJIC_CONFIG.SALJIC_CRAWLER_AD_CATEGORIES,
SALJIC_CONFIG.SALJIC_MAX_PAGES,
SALJIC_CONFIG.SALJIC_MAX_RESULTS_PER_PAGE,
SALJIC_CONFIG.SALJIC_IGNORED_USERNAMES,
SALJIC_CONFIG.SALJIC_DELAY_BETWEEN_PAGES
) )
]; ];

View File

@@ -5,10 +5,12 @@ const OLX_CONFIG = require("./specificConfigs/olx");
const RENTAL_CONFIG = require("./specificConfigs/rental"); const RENTAL_CONFIG = require("./specificConfigs/rental");
const PROSTOR_CONFIG = require("./specificConfigs/prostor"); const PROSTOR_CONFIG = require("./specificConfigs/prostor");
const AKTIDO_CONFIG = require("./specificConfigs/aktido"); const AKTIDO_CONFIG = require("./specificConfigs/aktido");
const SALJIC_CONFIG = require("./specificConfigs/saljic");
module.exports = { module.exports = {
OLX_CONFIG, OLX_CONFIG,
RENTAL_CONFIG, RENTAL_CONFIG,
PROSTOR_CONFIG, PROSTOR_CONFIG,
AKTIDO_CONFIG AKTIDO_CONFIG,
SALJIC_CONFIG
}; };

View File

@@ -1,6 +1,7 @@
const moment = require("moment"); const moment = require("moment");
const { bulkUpsertRealEstates } = require("../../helpers/db/realEstate"); const { bulkUpsertRealEstates } = require("../../helpers/db/realEstate");
const { bulkUpsertPriceHistory } = require("../../helpers/db/priceHistory");
class PostgresSaver { class PostgresSaver {
connect() { connect() {
@@ -11,6 +12,21 @@ class PostgresSaver {
async save(results) { async save(results) {
const savedRecords = await bulkUpsertRealEstates(results); const savedRecords = await bulkUpsertRealEstates(results);
//Extruding data for price history table
const resultPrices = savedRecords.map(realEstate => {
//Null values canot be recognized by ignore duplicates in sequalize
//Value price = 0 indicates 'cijena na upit'
const priceTmp =
realEstate.dataValues.price === null ? 0 : realEstate.dataValues.price;
return {
realEstateId: realEstate.dataValues.id,
price: priceTmp,
createdAt: realEstate.dataValues.createdAt,
updatedAt: realEstate.dataValues.updatedAt
};
});
const savedPrices = await bulkUpsertPriceHistory(resultPrices);
if (Array.isArray(savedRecords)) { if (Array.isArray(savedRecords)) {
const newRealEstates = []; const newRealEstates = [];

View File

@@ -29,5 +29,6 @@ module.exports = {
PROSTOR_CRAWLER_AD_CATEGORIES: transformedProstorCrawlerAdCategories, PROSTOR_CRAWLER_AD_CATEGORIES: transformedProstorCrawlerAdCategories,
PROSTOR_IGNORED_USERNAMES: prostorIgnoredUsernames || [], PROSTOR_IGNORED_USERNAMES: prostorIgnoredUsernames || [],
PROSTOR_DELAY_BETWEEN_PAGES: PROSTOR_DELAY_BETWEEN_PAGES:
parseInt(process.env.PROSTOR_DELAY_BETWEEN_PAGES) || 1000 parseInt(process.env.PROSTOR_DELAY_BETWEEN_PAGES) || 1000,
PROSTOR_FORCE_CRAWL: !!parseInt(process.env.PROSTOR_FORCE_CRAWL)
}; };

View File

@@ -0,0 +1,34 @@
"use strict";
const { CRAWLER_AD_TYPE, AD_CATEGORY } = require("../../common/enums");
const saljicCrawlerAdType =
process.env.SALJIC_CRAWLER_AD_TYPE !== undefined
? CRAWLER_AD_TYPE[process.env.SALJIC_CRAWLER_AD_TYPE]
: null;
const saljicParsedCrawlerAdCategories =
process.env.SALJIC_CRAWLER_AD_CATEGORIES !== undefined
? process.env.SALJIC_CRAWLER_AD_CATEGORIES.split(",").map(category =>
category.trim()
)
: ["FLAT", "HOUSE"];
const saljicIgnoredUsernames = [];
const transformedSaljicCrawlerAdCategories = saljicParsedCrawlerAdCategories
.map(categoryName =>
AD_CATEGORY[categoryName] ? AD_CATEGORY[categoryName].id : undefined
)
.filter(category => !!category);
module.exports = {
SALJIC_MAX_PAGES: parseInt(process.env.SALJIC_MAX_PAGES) || 100,
SALJIC_MAX_RESULTS_PER_PAGE:
parseInt(process.env.SALJIC_MAX_RESULTS_PER_PAGE) || 5000,
SALJIC_CRAWLER_AD_TYPE: saljicCrawlerAdType || CRAWLER_AD_TYPE.NONE,
SALJIC_CRAWLER_AD_CATEGORIES: transformedSaljicCrawlerAdCategories,
SALJIC_IGNORED_USERNAMES: saljicIgnoredUsernames || [],
SALJIC_DELAY_BETWEEN_PAGES:
parseInt(process.env.SALJIC_DELAY_BETWEEN_PAGES) || 1000,
SALJIC_FORCE_CRAWL: !!parseInt(process.env.SALJIC_FORCE_CRAWL)
};

View File

@@ -1,6 +1,6 @@
"use strict"; "use strict";
const fetch = require("node-fetch"); const fetch = require("../../helpers/fetchWrapper");
const cheerio = require("cheerio"); const cheerio = require("cheerio");
const Promise = require("bluebird"); const Promise = require("bluebird");
const moment = require("moment-timezone"); const moment = require("moment-timezone");
@@ -11,7 +11,10 @@ const {
AD_CATEGORY, AD_CATEGORY,
AD_AGENCY, AD_AGENCY,
AD_STATUS, AD_STATUS,
CRAWLER_AD_TYPE CRAWLER_AD_TYPE,
HEATING_TYPE,
ACCESS_ROAD_TYPE,
FURNISHING_TYPE
} = require("../../common/enums"); } = require("../../common/enums");
const { const {
@@ -219,6 +222,7 @@ class AktidoCrawler {
throw { message: "Can't find ad data JSON" }; throw { message: "Can't find ad data JSON" };
} }
let adStatus = AD_STATUS.STATUS_NORMAL;
const aktidoId = extractedData["re_realEstates_id"]; const aktidoId = extractedData["re_realEstates_id"];
const adCategory = this.getKiviCategoryIdFromAktidoId( const adCategory = this.getKiviCategoryIdFromAktidoId(
parseInt(extractedData["re_types_id"]) parseInt(extractedData["re_types_id"])
@@ -237,6 +241,181 @@ class AktidoCrawler {
}; };
} }
const descriptionIds = extractedData["re_descriptions_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(descriptionIds)) {
throw {
message:
'Expected array od descriptions but "re_descriptions_id" not found !'
};
}
const spaceIds = extractedData["re_spaces_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(spaceIds)) {
throw {
message: 'Expected array od spaces but "re_spaces_id" not found !'
};
}
const infrastructureIds = extractedData["re_infrastructure_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(infrastructureIds)) {
throw {
message:
'Expected array od infrastructures but "re_infrastructure_id" not found !'
};
}
const floorNoIds = extractedData["re_floorNO_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(floorNoIds)) {
throw {
message:
'Expected array od infrastructures but "re_floorNO_id" not found !'
};
}
// counting floor enums
// for (let i = 1; i < 10; i++) {
// const floorEnumsTitle = $(
// `body > div.container-fluid > div.container > div:nth-child(2) > div.col-xs-12.col-sm-12.col-md-12.col-lg-9.content-main > div:nth-child(1) > div > div > div.col-xs-12.col-sm-4.box-details > div.body > p:nth-child(${i}) > span:nth-child(1)`
// )
// .text()
// .trim();
// if (floorEnumsTitle === "Spratnost:") {
// const floorEnumsValue = $(
// `body > div.container-fluid > div.container > div:nth-child(2) > div.col-xs-12.col-sm-12.col-md-12.col-lg-9.content-main > div:nth-child(1) > div > div > div.col-xs-12.col-sm-4.box-details > div.body > p:nth-child(${i}) > span:nth-child(2)`
// )
// .text()
// .trim()
// .split(",");
//
// console.log("==========");
// floorNoIds.forEach((id, index) => {
// console.log("\t", id, " = ", floorEnumsValue[index]);
// });
// break;
// }
// }
// enumerating infrastructure - relation between id and infrastructure title
// let found = false;
// let infrastructureDescriptions = {};
// for (let i = 1; i < 5; i++) {
// found = false;
// for (let j = 1; j < 10; j++) {
// const infrastructureTitle = $(
// `#b2 > div > div:nth-child(${i}) > div > ul > li:nth-child(${j}) > strong`
// )
// .text()
// .trim();
// if (infrastructureTitle === "Osnovna infrastruktura:") {
// found = true;
//
// const infrastructureValues = $(
// `#b2 > div > div:nth-child(${i}) > div > ul > li:nth-child(${j}) > div`
// )
// .text()
// .trim()
// .split(",");
//
// infrastructureIds.forEach((id, index) => {
// infrastructureDescriptions[id] = infrastructureValues[index];
// });
// }
// }
// if (found) {
// break;
// }
// }
const realEstatePropertiesFromDescriptions = this.getPropertiesFromDescriptions(
descriptionIds
);
const realEstatePropertiesFromSpaces = this.getPropertiesFromSpaces(
spaceIds
);
const realEstatePropertiesFromInfrastructure = this.getPropertiesFromInfrastructure(
infrastructureIds
);
if (extractedData["adm_realEstates_discount"] === "1") {
adStatus = AD_STATUS.STATUS_DISCOUNTED;
}
let numberOfRooms =
parseInt(extractedData["re_realEstates_roomsNO"]) +
parseInt(extractedData["re_realEstates_bedroomNO"]) || null,
numberOfFloors =
parseInt(extractedData["re_realEstates_floorsNO"]) ||
this.getNumberOfFloorsFromFloorId(extractedData["re_floorNO_id"]),
floor =
parseInt(extractedData["re_realEstates_floorNO"]) ||
this.getFloorNumberFromFloorId(extractedData["re_floorNO_id"]),
accessRoadType = realEstatePropertiesFromDescriptions.accessRoadType,
heatingType =
this.getHeatingTypeId(extractedData["re_heating_id"]) || null,
furnishingType = realEstatePropertiesFromDescriptions.furnishingType,
balcony =
realEstatePropertiesFromDescriptions.balcony ||
realEstatePropertiesFromSpaces.balcony,
newBuilding = extractedData["op_realEstates_newBuilding"]
? extractedData["op_realEstates_newBuilding"] === "1"
: null,
elevator = realEstatePropertiesFromDescriptions.elevator,
water =
realEstatePropertiesFromDescriptions.water ||
realEstatePropertiesFromInfrastructure.water,
electricity =
realEstatePropertiesFromDescriptions.electricity ||
realEstatePropertiesFromInfrastructure.electricity,
drainageSystem =
realEstatePropertiesFromInfrastructure.drainageSystem,
registeredInZkBooks =
extractedData["op_realEstates_ownerPermit"] === 1 || null,
recentlyAdapted = null,
parking =
realEstatePropertiesFromDescriptions.parking ||
realEstatePropertiesFromSpaces.parking,
garage = realEstatePropertiesFromSpaces.garage,
gas = realEstatePropertiesFromInfrastructure.gas,
antiTheftDoor = realEstatePropertiesFromDescriptions.antiTheftDoor,
airCondition = realEstatePropertiesFromDescriptions.airCondition,
phoneConnection =
realEstatePropertiesFromInfrastructure.phoneConnection,
cableTV = realEstatePropertiesFromInfrastructure.cableTV,
internet = realEstatePropertiesFromInfrastructure.internet,
basementAttic = realEstatePropertiesFromSpaces.basementAttic,
storeRoom = realEstatePropertiesFromSpaces.storeRoom,
videoSurveillance =
realEstatePropertiesFromDescriptions.videoSurveillance ||
realEstatePropertiesFromInfrastructure.videoSurveillance,
alarm = realEstatePropertiesFromDescriptions.alarm,
suitableForStudents = null,
includingBills =
extractedData["op_realEstates_utilitiesIncluded"] === "1" || null,
animalsAllowed = null,
pool = realEstatePropertiesFromDescriptions.pool,
urbanPlanPermit =
extractedData["op_realEstates_locationPermit"] === "1" ||
realEstatePropertiesFromDescriptions.urbanPlanPermit,
buildingPermit =
extractedData["op_realEstates_buildingPermit"] === "1" || null,
utilityConnection =
realEstatePropertiesFromDescriptions.utilityConnection,
distanceToRiver = null,
numberOfViewsAgency = null;
const title = extractedData["re_realEstates_portalName"]; const title = extractedData["re_realEstates_portalName"];
const extractedPrice = parseFloat( const extractedPrice = parseFloat(
extractedData["re_realEstates_price"] extractedData["re_realEstates_price"]
@@ -277,8 +456,6 @@ class AktidoCrawler {
}; };
} }
const adStatus = AD_STATUS.STATUS_NORMAL;
const data = { const data = {
url, url,
agencyObjectId: aktidoId, agencyObjectId: aktidoId,
@@ -303,7 +480,42 @@ class AktidoCrawler {
locationLong, locationLong,
adStatus, adStatus,
publishedDate: publishedDateMoment.toISOString(), publishedDate: publishedDateMoment.toISOString(),
renewedDate: renewedDateMoment.toISOString() renewedDate: renewedDateMoment.toISOString(),
numberOfRooms,
numberOfFloors,
floor,
accessRoadType,
heatingType,
furnishingType,
balcony,
newBuilding,
elevator,
water,
electricity,
drainageSystem,
registeredInZkBooks,
recentlyAdapted,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
urbanPlanPermit,
buildingPermit,
utilityConnection,
distanceToRiver,
numberOfViewsAgency
}; };
return data; return data;
@@ -350,6 +562,270 @@ class AktidoCrawler {
} }
} }
getPropertiesFromDescriptions(descriptionIds) {
const result = {
accessRoadType: null,
furnishingType: null,
balcony: null,
elevator: null,
parking: null,
antiTheftDoor: null,
airCondition: null,
videoSurveillance: null,
alarm: null,
pool: null,
urbanPlanPermit: null,
utilityConnection: null,
water: null,
electricity: null
};
for (const descriptionId of descriptionIds) {
switch (descriptionId) {
case 16:
result.furnishingType = FURNISHING_TYPE.NOT_FURNISHED.id;
break;
case 17:
result.furnishingType = FURNISHING_TYPE.HALF_FURNISHED.id;
break;
case 1:
case 28:
result.furnishingType = FURNISHING_TYPE.FURNISHED.id;
break;
case 14:
result.elevator = true;
break;
case 39:
result.electricity = true;
break;
case 40:
result.water = true;
break;
case 41:
case 58:
result.accessRoadType = ACCESS_ROAD_TYPE.ASPHALT.id;
break;
case 26:
result.balcony = true;
break;
case 62:
result.parking = true;
break;
case 3:
result.antiTheftDoor = true;
break;
case 2:
case 21:
result.airCondition = true;
break;
case 4:
result.alarm = true;
break;
case 55:
result.videoSurveillance = true;
break;
case 9:
result.pool = true;
break;
case 60:
result.urbanPlanPermit = true;
break;
case 38:
result.utilityConnection = true;
break;
}
}
return result;
}
getPropertiesFromSpaces(spaceIds) {
const result = {
balcony: null,
parking: null,
garage: null,
basementAttic: null,
storeRoom: null
};
for (const spaceId of spaceIds) {
switch (spaceId) {
case 36:
case 12:
result.parking = true;
break;
case 1:
case 2:
case 3:
result.balcony = true;
break;
case 4:
case 30:
result.garage = true;
break;
case 9:
case 10:
result.storeRoom = true;
break;
case 18:
case 34:
case 37:
case 27:
result.basementAttic = true;
break;
}
}
return result;
}
getHeatingTypeId(heatingRentalId) {
// heatingRentalId can have multiple values, like: "1, 2, 3", parseInt will take first integer value
const heatingId = parseInt(heatingRentalId);
switch (heatingId) {
case 27:
case 16:
return HEATING_TYPE.GAS.id;
case 4:
return HEATING_TYPE.CENTRAL_GAS.id;
case 3:
case 23:
case 6:
case 7:
case 8:
case 9:
case 10:
return HEATING_TYPE.CENTRAL_BOILER.id;
case 2:
case 13:
case 30:
case 17:
case 29:
case 31:
return HEATING_TYPE.ELECTRICITY.id;
case 24:
case 25:
case 12:
return HEATING_TYPE.CENTRAL_CITY.id;
case 26:
case 21:
case 20:
return HEATING_TYPE.WOOD.id;
case 28:
case 19:
return HEATING_TYPE.HEAT_PUMP.id;
case 14:
case 32:
return HEATING_TYPE.OTHER.id;
default:
return null;
}
}
getPropertiesFromInfrastructure(infrastructureIds) {
const result = {
electricity: null,
water: null,
gas: null,
drainageSystem: null,
phoneConnection: null,
internet: null,
videoSurveillance: null,
cableTV: null
};
for (const infrastructureId of infrastructureIds) {
switch (infrastructureId) {
case 1:
result.electricity = true;
break;
case 2:
result.water = true;
break;
case 4:
result.gas = true;
break;
case 5:
result.drainageSystem = true;
break;
case 7:
case 8:
result.phoneConnection = true;
break;
case 10:
result.internet = true;
break;
case 11:
result.cableTV = true;
break;
case 16:
case 17:
result.videoSurveillance = true;
break;
}
}
return result;
}
getFloorNumberFromFloorId(floorsIdText) {
// floorIdText can be array of numbers, separated by comma or number
// just extracting floor number from first element
const floorsId = floorsIdText.split(",");
if (floorsId.length === 0) {
return null;
}
const firstFloorId = parseInt(floorsId[0]);
// 1 pod
// 2 sut
// 3 raz
// 4 pri
// 5 vpri
// 6 prv
// 7 dru
// 8 tre
// 9 čet
// 10 man
// 11
// 12 pot
// 13 vpot
// 14 tav
// 15 pet
const floorNumber = [
-1,
-1,
0,
0,
1,
1,
2,
3,
4,
null,
null,
null,
null,
null,
5
];
return floorNumber[firstFloorId - 1] || null;
}
getNumberOfFloorsFromFloorId(floorsIdText) {
// floorIdText can be array of numbers, separated by comma or number
const floorIds = floorsIdText.split(",");
if (floorIds.length === 0) {
return null;
}
return floorIds.length;
}
async sleep(ms) { async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
@@ -362,7 +838,7 @@ class AktidoCrawler {
// } // }
//For now, we use only Postgres saver, so ... //For now, we use only Postgres saver, so ...
return await savers[0].save(results); return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted //so that we can use some sequelize options and information when data is inserted
} }
} }

View File

@@ -1,6 +1,6 @@
"use strict"; "use strict";
const fetch = require("node-fetch"); const fetch = require("../../helpers/fetchWrapper");
const cheerio = require("cheerio"); const cheerio = require("cheerio");
const Promise = require("bluebird"); const Promise = require("bluebird");
const moment = require("moment-timezone"); const moment = require("moment-timezone");
@@ -10,7 +10,10 @@ const {
AD_CATEGORY, AD_CATEGORY,
AD_AGENCY, AD_AGENCY,
AD_STATUS, AD_STATUS,
CRAWLER_AD_TYPE CRAWLER_AD_TYPE,
HEATING_TYPE,
FURNISHING_TYPE,
ACCESS_ROAD_TYPE
} = require("../../common/enums"); } = require("../../common/enums");
const { const {
@@ -271,6 +274,7 @@ class OlxCrawler {
//====== OTHER AD INFORMATION =============== //====== OTHER AD INFORMATION ===============
let adType = null; let adType = null;
let olxId = null; let olxId = null;
let numberOfViewsAgency = null;
let otherInformationDivId; let otherInformationDivId;
//We need to locate DIV ID where other information are stored //We need to locate DIV ID where other information are stored
@@ -293,6 +297,7 @@ class OlxCrawler {
const olxIdFieldSelector = `#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${otherInformationDivId}) > div:nth-child(4)`; const olxIdFieldSelector = `#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${otherInformationDivId}) > div:nth-child(4)`;
const publishedDateValueSelector = `#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${otherInformationDivId}) > div:nth-child(3) > div.df2.neanimiraj > time`; const publishedDateValueSelector = `#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${otherInformationDivId}) > div:nth-child(3) > div.df2.neanimiraj > time`;
const numberOfViewsAgencyValueSelector = `#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${otherInformationDivId}) > div:nth-child(6) > div.df2`;
const renewedDateFullValueSelector = `#artikal_glavni_div > div.artikal_lijevo > div.op.ob.pop`; const renewedDateFullValueSelector = `#artikal_glavni_div > div.artikal_lijevo > div.op.ob.pop`;
const publishedDate = $(publishedDateValueSelector) const publishedDate = $(publishedDateValueSelector)
@@ -331,60 +336,7 @@ class OlxCrawler {
) )
.text() .text()
.trim(); .trim();
const olxIdFieldTitle = $(`${olxIdFieldSelector} > div.df1`)
.text()
.trim();
olxId = $(`${olxIdFieldSelector} > div.df2`)
.text()
.trim();
if (olxIdFieldTitle !== "OLX ID") {
throw { message: "Cannot find correct OLX ID" };
}
//===========================================
//====== DETAIL INFORMATION FIELDS ==========
let area = null;
let gardenSize = null;
let fieldIndex = 1;
do {
const fieldSelector = `#dodatnapolja1 > div:nth-child(${fieldIndex})`;
const fieldTitleSelector = `${fieldSelector} > div.df1`;
const fieldValueSelector = `${fieldSelector} > div.df2`;
const fieldTitle = $(fieldTitleSelector)
.text()
.trim();
const fieldValue = $(fieldValueSelector)
.text()
.trim();
switch (fieldTitle) {
case "Kvadrata":
area = fieldValue;
break;
case "Okućnica (kvadratura)":
gardenSize = fieldValue;
break;
}
if (++fieldIndex === OLX_ENUMS.MAX_DETAIL_FIELDS || fieldTitle === "") {
break;
}
} while (true);
//===========================================
//====== UNUSED FIELDS FOR NOW ==============
const time = $("time").attr("datetime");
const numberOfViews = $(
"#artikal_glavni_div > div.artikal_lijevo > div:nth-child(18) > div:nth-child(6) > div.df2"
)
.text()
.trim();
//===========================================
//=========================================
const parsedCategory = this.getAdCategoryId(category); const parsedCategory = this.getAdCategoryId(category);
if (!parsedCategory) { if (!parsedCategory) {
throw { message: `Unknown ad category [${category}]` }; throw { message: `Unknown ad category [${category}]` };
@@ -395,6 +347,221 @@ class OlxCrawler {
throw { message: "Unknown ad type" }; throw { message: "Unknown ad type" };
} }
const olxIdFieldTitle = $(`${olxIdFieldSelector} > div.df1`)
.text()
.trim();
olxId = $(`${olxIdFieldSelector} > div.df2`)
.text()
.trim();
numberOfViewsAgency = parseInt(
$(numberOfViewsAgencyValueSelector)
.text()
.trim()
);
if (olxIdFieldTitle !== "OLX ID") {
throw { message: "Cannot find correct OLX ID" };
}
//===========================================
//====== DETAIL INFORMATION FIELDS ==========
let area,
gardenSize,
numberOfRooms = null,
numberOfFloors = null,
floor = null,
accessRoadType = null,
heatingType = null,
furnishingType = null,
balcony = null,
newBuilding = null,
elevator = null,
water = null,
electricity = null,
drainageSystem = null,
registeredInZkBooks = null,
recentlyAdapted = null,
parking = null,
garage = null,
gas = null,
antiTheftDoor = null,
airCondition = null,
phoneConnection = null,
cableTV = null,
internet = null,
basementAttic = null,
storeRoom = null,
videoSurveillance = null,
alarm = null,
suitableForStudents = null,
includingBills = null,
animalsAllowed = null,
pool = null,
urbanPlanPermit = null,
buildingPermit = null,
utilityConnection = null,
distanceToRiver = null;
let fieldIndex = 1;
do {
const fieldSelector = `#dodatnapolja1 > div:nth-child(${fieldIndex})`;
const fieldTitleSelector = `${fieldSelector} > div.df1`;
const fieldValueSelector = `${fieldSelector} > div.df2`;
const fieldTitle = $(fieldTitleSelector)
.text()
.trim()
.toLowerCase();
const fieldValue = $(fieldValueSelector)
.text()
.trim()
.toLowerCase();
switch (fieldTitle) {
case "kvadrata":
area = fieldValue;
break;
case "okućnica (kvadratura)":
gardenSize = fieldValue;
break;
case "broj soba":
numberOfRooms = this.parseNumberOfRooms(fieldValue, parsedCategory);
break;
case "broj prostorija":
numberOfRooms = this.parseNumberOfRooms(fieldValue, parsedCategory);
break;
case "broj spratova":
numberOfFloors = this.parseNumberOfFloors(
fieldValue,
parsedCategory
);
break;
case "sprat":
floor = this.parseFloorNumber(fieldValue, parsedCategory);
break;
case "vrsta grijanja":
heatingType = this.getHeatingTypeId(fieldValue);
break;
case "namješten?":
furnishingType = this.getFurnishingTypeId(fieldValue);
break;
case "namješten":
furnishingType = FURNISHING_TYPE.FURNISHED.id;
break;
case "namještena":
furnishingType = FURNISHING_TYPE.FURNISHED.id;
break;
case "voda":
water = true;
break;
case "struja":
electricity = true;
break;
case "kanalizacija":
drainageSystem = fieldValue !== "nema";
break;
case "godina izgradnje":
newBuilding = newBuilding || fieldValue === "novogradnja";
break;
case "kućni ljubimci":
animalsAllowed = fieldValue === "da";
break;
case "uknjiženo / zk":
registeredInZkBooks = true;
break;
case "uknjiženo (zk)":
registeredInZkBooks = true;
break;
case "novogradnja":
newBuilding = true;
break;
case "nedavno adaptiran":
recentlyAdapted = true;
break;
case "nedavno adaptirana":
recentlyAdapted = true;
break;
case "balkon":
balcony = true;
break;
case "lift":
elevator = true;
break;
case "parking":
parking = true;
break;
case "garaža":
garage = true;
break;
case "plin":
gas = true;
break;
case "blindirana vrata":
antiTheftDoor = true;
break;
case "klima":
airCondition = true;
break;
case "telefonski priključak":
phoneConnection = true;
break;
case "kablovska tv":
cableTV = true;
break;
case "internet":
internet = true;
break;
case "podrum/tavan":
basementAttic = true;
break;
case "ostava/špajz":
storeRoom = true;
break;
case "video nadzor":
videoSurveillance = true;
break;
case "alarm":
alarm = true;
break;
case "za studente":
suitableForStudents = true;
break;
case "uključen trošak režija":
includingBills = true;
break;
case "građevinska dozvola":
buildingPermit = true;
break;
case "komunalni priključak":
utilityConnection = true;
break;
case "urbanistička dozvola":
urbanPlanPermit = true;
break;
case "udaljenost od rijeke (m)":
distanceToRiver = parseInt(fieldValue) || null;
break;
case "prilaz":
accessRoadType = this.getAccessRoadTypeId(fieldValue);
break;
case "bazen":
pool = true;
break;
case "iznajmljeno":
status = AD_STATUS.STATUS_RENTED;
break;
default:
// console.log(fieldTitle, " = ", fieldValue);
break;
}
if (++fieldIndex === OLX_ENUMS.MAX_DETAIL_FIELDS || fieldTitle === "") {
break;
}
} while (true);
//===========================================
//=========================================
const parsedArea = this.parseArea(area) || null; const parsedArea = this.parseArea(area) || null;
const parsedGardenSize = this.parseArea(gardenSize) || null; const parsedGardenSize = this.parseArea(gardenSize) || null;
const parsedPrice = this.parsePrice(price) || null; const parsedPrice = this.parsePrice(price) || null;
@@ -409,6 +576,13 @@ class OlxCrawler {
locationLong = parseFloat(locationLatLngMatches[2]) || null; locationLong = parseFloat(locationLatLngMatches[2]) || null;
} }
if (
title.indexOf("[PRODANO]") !== -1 ||
title.indexOf("[ZAVRŠENO]") !== -1
) {
status = AD_STATUS.STATUS_SOLD;
}
const data = { const data = {
url, url,
agencyObjectId: olxId, agencyObjectId: olxId,
@@ -439,7 +613,42 @@ class OlxCrawler {
locationLong, locationLong,
adStatus: status, adStatus: status,
publishedDate: publishedDateMoment.toISOString(), publishedDate: publishedDateMoment.toISOString(),
renewedDate: renewedDateMoment.toISOString() renewedDate: renewedDateMoment.toISOString(),
numberOfRooms,
numberOfFloors,
floor,
accessRoadType,
heatingType,
furnishingType,
balcony,
newBuilding,
elevator,
water,
electricity,
drainageSystem,
registeredInZkBooks,
recentlyAdapted,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
urbanPlanPermit,
buildingPermit,
utilityConnection,
distanceToRiver,
numberOfViewsAgency
}; };
return data; return data;
@@ -479,12 +688,70 @@ class OlxCrawler {
case "Izdavanje": case "Izdavanje":
return AD_TYPE.AD_TYPE_RENT.stringId; return AD_TYPE.AD_TYPE_RENT.stringId;
case "Potražnja": case "Potražnja":
return AD_TYPE.AD_TYPE_RENT.stringId; return AD_TYPE.AD_TYPE_REQUEST.stringId;
default: default:
return undefined; return undefined;
} }
} }
getHeatingTypeId(heatingTypeText) {
switch (heatingTypeText) {
case "struja":
return HEATING_TYPE.ELECTRICITY.id;
case "plin":
return HEATING_TYPE.GAS.id;
case "drva":
return HEATING_TYPE.WOOD.id;
case "centralno (gradsko)":
return HEATING_TYPE.CENTRAL_CITY.id;
case "centralno (kotlovnica)":
return HEATING_TYPE.CENTRAL_BOILER.id;
case "centralno (plin)":
return HEATING_TYPE.CENTRAL_GAS.id;
case "nije uvedeno":
return HEATING_TYPE.NO_HEATING.id;
case "ostalo":
return HEATING_TYPE.OTHER.id;
case "drugo":
return HEATING_TYPE.OTHER.id;
default:
console.log("grijanje = NEPOZNATO [", heatingTypeText, "]");
return null;
}
}
getFurnishingTypeId(furnishingTypeText) {
switch (furnishingTypeText) {
case "namješten":
return FURNISHING_TYPE.FURNISHED.id;
case "polunamješten":
return FURNISHING_TYPE.HALF_FURNISHED.id;
case "nenamješten":
return FURNISHING_TYPE.NOT_FURNISHED.id;
case "":
return FURNISHING_TYPE.FURNISHED.id;
default:
console.log("namješten = NEPOZNATO [", furnishingTypeText, "]");
return null;
}
}
getAccessRoadTypeId(accessRoadTypeText) {
switch (accessRoadTypeText) {
case "asfalt":
return ACCESS_ROAD_TYPE.ASPHALT.id;
case "beton":
return ACCESS_ROAD_TYPE.CONCRETE.id;
case "makadam":
return ACCESS_ROAD_TYPE.MACADAM.id;
case "ostalo":
return ACCESS_ROAD_TYPE.OTHER.id;
default:
console.log("pristup = NEPOZNATO [", accessRoadTypeText, "]");
return null;
}
}
parseArea(areaText) { parseArea(areaText) {
if (!areaText) { if (!areaText) {
return NaN; return NaN;
@@ -505,56 +772,100 @@ class OlxCrawler {
return parseFloat(formattedPriceText); return parseFloat(formattedPriceText);
} }
parseRenewedDate(renewedDateText) { parseNumberOfRooms(numberOfRoomsText, categoryId) {
const currentMoment = moment.tz(DEFAULT_TIMEZONE); if (categoryId === AD_CATEGORY.FLAT.id) {
switch (numberOfRoomsText) {
if (renewedDateText.includes("Prije mjesec dana")) { case "garsonjera":
return currentMoment.add(-1, "month"); return 0;
} case "jednosoban (1)":
return 1;
if (renewedDateText.includes("Jučer")) { case "jednoiposoban (1.5)":
return currentMoment.add(-1, "day"); return 1.5;
} case "dvosoban (2)":
return 2;
if (renewedDateText.includes("Prije sat")) { case "trosoban (3)":
return currentMoment.add(-1, "hour"); return 3;
} case "četverosoban (4)":
return 4;
if (renewedDateText.includes("dan")) { case "petosoban i više":
// format for this case should be "Prije N dana" or "Prije N dan" return 5;
const dateParts = renewedDateText.split(" "); default:
if (dateParts[0] === "Prije") { console.log(
const numberOfDays = parseInt(dateParts[1]); "broj soba [stan] = NEPOZNATO [",
return currentMoment.add(-1 * numberOfDays, "days"); numberOfRoomsText,
} else { ", ",
return undefined; categoryId,
} "]"
}
if (renewedDateText.includes("sat")) {
const dateParts = renewedDateText.split(" ");
const parsedHours =
dateParts && dateParts.length > 2 ? parseInt(dateParts[1]) : undefined;
if (!parsedHours) {
return undefined;
}
return currentMoment.add(-1 * parsedHours, "hours");
}
const todayVariations = ["min", "sekund", "maloprije"];
for (const todayVariation of todayVariations) {
if (renewedDateText.includes(todayVariation)) {
return currentMoment;
}
}
const renewedDateMoment = moment.tz(
renewedDateText,
OLX_ENUMS.OLX_RENEWED_DATE_FORMAT,
DEFAULT_TIMEZONE
); );
return null;
}
}
return renewedDateMoment.isValid() ? renewedDateMoment : undefined; if (
categoryId === AD_CATEGORY.HOUSE.id ||
categoryId === AD_CATEGORY.COTTAGE.id ||
categoryId === AD_CATEGORY.APARTMENT.id ||
categoryId === AD_CATEGORY.OFFICE.id
) {
return parseInt(numberOfRoomsText) || null;
}
console.log("broj soba = NEPOZNATO [", numberOfRoomsText, "]");
return null;
}
parseNumberOfFloors(numberOfFloorsText, categoryId) {
if (
categoryId === AD_CATEGORY.HOUSE.id ||
categoryId === AD_CATEGORY.COTTAGE.id
) {
return parseInt(numberOfFloorsText) || null;
}
if (categoryId === AD_CATEGORY.OFFICE.id) {
if (
numberOfFloorsText === "suteren" ||
numberOfFloorsText === "prizemlje"
) {
return 0;
}
if (numberOfFloorsText === "6+") {
return 7;
}
return parseInt(numberOfFloorsText) || null;
}
console.log("broj spratova = NEPOZNATO [", numberOfFloorsText, "]");
return null;
}
parseFloorNumber(floorText, categoryId) {
if (
categoryId === AD_CATEGORY.FLAT.id ||
categoryId === AD_CATEGORY.APARTMENT.id
) {
if (
floorText === "suteren" ||
floorText === "prizemlje" ||
floorText === "visoko prizemlje"
) {
return 0;
}
return parseInt(floorText) || null;
}
if (categoryId === AD_CATEGORY.OFFICE.id) {
if (floorText === "zaseban objekat") {
return null;
}
if (floorText === "prizemlje" || floorText === "visoko prizemlje") {
return 0;
}
return parseInt(floorText) || null;
}
console.log("sprat = NEPOZNATO [", floorText, "]");
return null;
} }
async sleep(ms) { async sleep(ms) {
@@ -569,7 +880,7 @@ class OlxCrawler {
// } // }
//For now, we use only Postgres saver, so ... //For now, we use only Postgres saver, so ...
return await savers[0].save(results); return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted //so that we can use some sequelize options and information when data is inserted
} }
} }

View File

@@ -1,17 +1,26 @@
"use strict"; "use strict";
const fetch = require("node-fetch"); const fetch = require("../../helpers/fetchWrapper");
const cheerio = require("cheerio"); const cheerio = require("cheerio");
const moment = require("moment-timezone");
const FormData = require("form-data");
const { const {
AD_TYPE, AD_TYPE,
AD_CATEGORY, AD_CATEGORY,
AD_AGENCY, AD_AGENCY,
AD_STATUS, AD_STATUS,
CRAWLER_AD_TYPE CRAWLER_AD_TYPE,
FURNISHING_TYPE,
HEATING_TYPE
} = require("../../common/enums"); } = require("../../common/enums");
const { PRINT_CRAWLER_DEBUG } = require("../../config/appConfig"); const {
PRINT_CRAWLER_DEBUG,
DEFAULT_TIMEZONE,
PROSTOR_LOGIN
} = require("../../config/appConfig");
const { PROSTOR_FORCE_CRAWL } = require("../specificConfigs/prostor");
const PROSTOR_ENUMS = { const PROSTOR_ENUMS = {
PROSTOR_AD_TYPE: { PROSTOR_AD_TYPE: {
@@ -48,44 +57,379 @@ class ProstorCrawler {
this.crawlerAdTypes = crawlerAdTypes; this.crawlerAdTypes = crawlerAdTypes;
this.crawlerAdCategories = crawlerAdCategories; this.crawlerAdCategories = crawlerAdCategories;
this.maxResultsPerPage = maxResultsPerPage; this.maxResultsPerPage = maxResultsPerPage;
this.delayBetweenPages = delayBetweenPages;
} }
async crawl() { async crawl() {
const crawlAdCategories = this.crawlerAdCategories; const crawlAdCategories = this.crawlerAdCategories;
//We need session cookie to use login privileges
const prostorCookie = await this.getCookies();
//New tag to check if crawler loged in
const login = await this.loginForScraping(PROSTOR_LOGIN, prostorCookie);
const newRealEstates = []; const newRealEstates = [];
//Crawl only if login was successful
if (crawlAdCategories) { if (crawlAdCategories && login) {
const indexGenerators = [];
for (const adCategory of crawlAdCategories) { for (const adCategory of crawlAdCategories) {
const urlAdTypePart = indexGenerators.push(this.categoryIndexer(adCategory, prostorCookie));
PROSTOR_ENUMS.PROSTOR_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = PROSTOR_ENUMS.PROSTOR_AD_CATEGORY[adCategory];
if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) {
const urlPageToCrawl = `${this.baseUrl}?remove_sold=1${urlAdTypePart}${urlCategoryPart}`;
const singleCategoryResults = await this.extractRealEstates(
urlPageToCrawl
);
const resultsSubset = singleCategoryResults.slice(
0,
this.maxResultsPerPage
);
const saveResults = await this.saveCrawledResults(resultsSubset);
const { newRecords } = saveResults;
newRealEstates.push(...newRecords);
} }
let done = false;
while (!done) {
const categoryIndexerPromises = [];
const generatorsToRemove = [];
for (const indexGenerator of indexGenerators) {
categoryIndexerPromises.push(indexGenerator.next());
generatorsToRemove.push(false);
}
const singlePageResults = await Promise.all(categoryIndexerPromises);
const entries = singlePageResults.entries();
for (const [index, { value: singlePageResult }] of entries) {
if (singlePageResult) {
const saveResults = await this.saveCrawledResults(singlePageResult);
const { newRecords } = saveResults;
newRealEstates.push(...newRecords);
if (
Array.isArray(newRecords) &&
newRecords.length === 0 &&
!PROSTOR_FORCE_CRAWL
) {
generatorsToRemove[index] = true;
}
} else {
//Generator returned undefined, remove this generator from array
generatorsToRemove[index] = true;
// console.log("Generator ", index + 1, "has no more pages");
}
}
// console.log("Generators state : ", generatorsToRemove);
for (let i = generatorsToRemove.length - 1; i >= 0; i--) {
if (generatorsToRemove[i]) {
// console.log("\tRemove generator ", i + 1);
indexGenerators.splice(i, 1);
}
}
if (indexGenerators.length === 0) {
done = true;
}
await this.sleep(this.delayBetweenPages);
} }
} }
return newRealEstates; return newRealEstates;
} }
async extractRealEstates(url) { async *categoryIndexer(adCategory, prostorCookie) {
const urlAdTypePart = PROSTOR_ENUMS.PROSTOR_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = PROSTOR_ENUMS.PROSTOR_AD_CATEGORY[adCategory];
if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) {
const urlPageToCrawl = `${this.baseUrl}?remove_sold=0${urlAdTypePart}${urlCategoryPart}`;
const listOfAllRealEstates = await this.extractRealEstates(
urlPageToCrawl,
prostorCookie
);
let elementToStartIndexFrom = 0;
while (true) {
const realEstatesForSinglePage = listOfAllRealEstates.slice(
elementToStartIndexFrom,
elementToStartIndexFrom + this.maxResultsPerPage
);
if (realEstatesForSinglePage.length > 0) {
elementToStartIndexFrom += realEstatesForSinglePage.length;
const singlePageResults = await this.indexSinglePage(
realEstatesForSinglePage,
prostorCookie
);
const filteredSinglePageResults = singlePageResults.filter(
singleResult => !!singleResult
);
if (
Array.isArray(filteredSinglePageResults) &&
filteredSinglePageResults.length > 0
) {
yield filteredSinglePageResults;
} else {
return undefined;
}
} else {
return undefined;
}
}
} else {
return undefined;
}
}
async indexSinglePage(realEstatesList, prostorCookie) {
const asyncActions = [];
for (const realEstate of realEstatesList) {
asyncActions.push(this.scrapeAd(realEstate, prostorCookie));
}
try {
return await Promise.all(asyncActions);
} catch (e) {
console.log(
"[PROSTOR] Error crawling ads : ",
e.message || "UNKNOWN ERROR"
);
return [];
}
}
async scrapeAd(realEstate, prostorCookie) {
const { lat, lng, property_name, price, size, link, status } = realEstate;
//Status information is given already in realestate list
const adStatus = ProstorCrawler.getStatusId(status);
const url = `https://prostor.ba${link}`;
// console.log("[PROSTOR] Scraping : ", url);
try {
const adPageSource = await fetch(url, {
headers: { Cookie: prostorCookie }
});
const body = await adPageSource.text();
const $ = cheerio.load(body);
// link contains part of the URL in the format of : /prodaja/stan/stup/9556
// general form is : /actionType/realEstateType/location/realEstateID
// linkParts contains : ['', 'actionType', 'realEstateType', 'location', 'realEstateID']
const linkParts = link.split("/");
const adType = ProstorCrawler.getAdTypeId(linkParts[1]);
const realEstateType = ProstorCrawler.getAdCategoryId(linkParts[2]);
const prostorId = linkParts[4];
if (!adType || !realEstateType || !prostorId) {
return null;
}
const allDataSelector =
"body > div > div.container-fluid > div > div.column-right > table > tbody";
const realEstateProperties = {};
$(allDataSelector)
.find("p")
.each((i, element) => {
const propertyElement = $(element)
.text()
.split(":")
.map(text => text.trim().toLowerCase());
const propertyTitle = propertyElement[0];
realEstateProperties[propertyTitle] = propertyElement[1];
});
$(allDataSelector)
.find("div.mb-2")
.each((i, element) => {
const propertyElement = $(element)
.text()
.trim()
.toLowerCase();
realEstateProperties[propertyElement] = true;
});
if (JSON.stringify(realEstateProperties) === JSON.stringify({})) {
return null;
}
let numberOfRooms =
parseFloat(realEstateProperties["broj soba"]) +
parseFloat(realEstateProperties["broj spavaćih soba"]) || null,
numberOfFloors = null,
floor = null,
accessRoadType = null,
heatingType = ProstorCrawler.getHeatingTypeId(realEstateProperties),
furnishingType = null,
balcony =
realEstateProperties["balkon"] ||
realEstateProperties["terasa"] ||
realEstateProperties["lođa"] ||
null,
newBuilding = linkParts[1] === "novogradnja",
elevator = realEstateProperties["lift"] || null,
water = realEstateProperties["voda"] || null,
electricity = realEstateProperties["električna energija"] || null,
drainageSystem = realEstateProperties["kanalizacija"] || null,
registeredInZkBooks = null,
recentlyAdapted = null,
parking = realEstateProperties["parking"] || null,
garage = realEstateProperties["garaža"] || null,
gas = realEstateProperties["plin"] || null,
antiTheftDoor = realEstateProperties["blindo vrata"] || null,
airCondition = realEstateProperties["klima"] || null,
phoneConnection = realEstateProperties["telefon"] || null,
cableTV = realEstateProperties["kablovksa tv"] || null,
internet =
realEstateProperties["internet"] ||
realEstateProperties["adsl"] ||
null,
basementAttic = realEstateProperties["podrum"] || null,
storeRoom = realEstateProperties["ostava"] || null,
videoSurveillance = realEstateProperties["video nadzor"],
alarm = realEstateProperties["alarm"] || null,
suitableForStudents = null,
includingBills = null,
animalsAllowed = null,
pool = realEstateProperties["bazen"] || null,
urbanPlanPermit = null,
buildingPermit = null,
utilityConnection = null,
distanceToRiver = null,
numberOfViewsAgency = null;
// Floor versions (there are possibly more versions) :
// Sprat: 3/3
// Sprat: 1 - 2/2
// Sprat: Pr - 7/7
// Sprat: -2/0
// If there are two parts, that represents more real estates are sold
// numberOfFloors is contained in second part, after / sign
const floorsArray = realEstateProperties["sprat"].split(" - ");
let floorText = "";
if (floorsArray.length === 1) {
const floorDescription = floorsArray[0].split("/");
numberOfFloors = parseInt(floorDescription[1]) || null;
floorText = floorDescription[0];
floor = Math.round(parseFloat(floorText));
} else if (floorsArray.length === 2) {
const floorDescription = floorsArray[1].split("/");
numberOfFloors = parseInt(floorDescription[1]) || null;
floorText = floorsArray[0];
floor = Math.round(parseFloat(floorText));
} else {
// This is something strange
}
if (isNaN(floor)) {
// It was textual representation of floor, like "Pr", "Su" or similar
switch (floorText) {
case "pr":
floor = 0;
break;
case "su":
floor = -1;
break;
default:
console.log(
"[PROSTOR] Unknown textual representation of floor : ",
floorText
);
floor = null;
}
}
if (realEstateProperties["namješteno"]) {
furnishingType = FURNISHING_TYPE.FURNISHED.id;
} else if (realEstateProperties["polunamješteno"]) {
furnishingType = FURNISHING_TYPE.HALF_FURNISHED.id;
} else {
furnishingType = FURNISHING_TYPE.NOT_FURNISHED.id;
}
const title = property_name;
const parsedPrice = parseFloat(price.replace(/\./g, "")) || null;
const parsedArea = parseFloat(size);
const gardenSize = null;
const longDescription = null;
const data = {
url,
agencyObjectId: prostorId,
originAgencyName: AD_AGENCY.PROSTOR,
realEstateType,
adType,
title,
price: parsedPrice,
area: parsedArea,
gardenSize,
shortDescription: "",
longDescription: longDescription,
streetNumber: 0,
streetName: realEstateProperties["adresa"],
locality: "",
municipality: "",
city: "",
region: "",
entity: "",
country: "",
locationLat: lat,
locationLong: lng,
adStatus,
numberOfRooms,
numberOfFloors,
floor,
accessRoadType,
heatingType,
furnishingType,
balcony,
newBuilding,
elevator,
water,
electricity,
drainageSystem,
registeredInZkBooks,
recentlyAdapted,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
urbanPlanPermit,
buildingPermit,
utilityConnection,
distanceToRiver,
numberOfViewsAgency
};
return data;
} catch (e) {
console.error(
"[PROSTOR] Exception caught: " + e.message,
"\r\nURL:",
url
);
return null;
}
}
async extractRealEstates(url, prostorCookie) {
if (PRINT_CRAWLER_DEBUG) { if (PRINT_CRAWLER_DEBUG) {
console.log("[PROSTOR] Index page : ", url); console.log("[PROSTOR] Index page : ", url);
} }
try { try {
const res = await fetch(url); const res = await fetch(url, {
headers: { Cookie: prostorCookie }
});
const body = await res.text(); const body = await res.text();
const $ = cheerio.load(body); const $ = cheerio.load(body);
@@ -115,18 +459,19 @@ class ProstorCrawler {
const jsonData = scriptData.substring(23, jsonEndIndex) + "]"; const jsonData = scriptData.substring(23, jsonEndIndex) + "]";
const realEstates = JSON.parse(jsonData); const realEstates = JSON.parse(jsonData);
const transformedRealEstates = []; // const transformedRealEstates = [];
//
for (const realEstate of realEstates) { // for (const realEstate of realEstates) {
const transformedRealEstate = ProstorCrawler.transformRealEstateData( // const transformedRealEstate = ProstorCrawler.transformRealEstateData(
realEstate // realEstate
); // );
if (transformedRealEstate) { // if (transformedRealEstate) {
transformedRealEstates.push(transformedRealEstate); // transformedRealEstates.push(transformedRealEstate);
} // }
} // }
//
return transformedRealEstates; // return transformedRealEstates;
return realEstates;
} else { } else {
throw { throw {
message: "Something is wrong with JSON data or data is moved" message: "Something is wrong with JSON data or data is moved"
@@ -134,73 +479,15 @@ class ProstorCrawler {
} }
} catch (e) { } catch (e) {
console.log(e); console.log(e);
throw { message: "Can't find ad data JSON" }; throw e;
} }
} }
} catch (e) {
console.error("[PROSTOR] Exception caught:", e.message);
return [];
}
}
static transformRealEstateData(realEstateData) {
try {
const { lat, lng, property_name, price, size, link } = realEstateData;
// link contains part of the URL in the format of : /prodaja/stan/stup/9556
// general form is : /actionType/realEstateType/location/realEstateID
// linkParts contains : ['', 'actionType', 'realEstateType', 'location', 'realEstateID']
const linkParts = link.split("/");
const adType = ProstorCrawler.getAdTypeId(linkParts[1]);
const realEstateType = ProstorCrawler.getAdCategoryId(linkParts[2]);
const prostorId = linkParts[4];
const url = `https://prostor.ba${link}`;
if (!adType || !realEstateType || !prostorId) {
return null;
}
const adStatus = AD_STATUS.STATUS_NORMAL;
const parsedPrice = parseFloat(price.replace(/\./g, "")) || null;
const parsedArea = parseFloat(size);
const data = {
url,
agencyObjectId: prostorId,
originAgencyName: AD_AGENCY.PROSTOR,
realEstateType,
adType,
title: property_name,
price: parsedPrice,
area: parsedArea,
gardenSize: null,
shortDescription: "",
longDescription: "",
streetNumber: 0,
streetName: "",
locality: "",
municipality: "",
city: "",
region: "",
entity: "",
country: "",
locationLat: lat,
locationLong: lng,
adStatus,
publishedDate: null,
renewedDate: null
};
return data;
} catch (e) { } catch (e) {
console.error( console.error(
"[PROSTOR] Exception caught: " + e.message, "[PROSTOR] Exception caught:",
"\r\nURL:", e.message || "UNKNOWN MESSAGE"
url
); );
return null; return [];
} }
} }
@@ -231,11 +518,63 @@ class ProstorCrawler {
return AD_TYPE.AD_TYPE_SALE.stringId; return AD_TYPE.AD_TYPE_SALE.stringId;
case "najam": case "najam":
return AD_TYPE.AD_TYPE_RENT.stringId; return AD_TYPE.AD_TYPE_RENT.stringId;
case "novogradnja":
return AD_TYPE.AD_TYPE_SALE.stringId;
default: default:
return undefined; return undefined;
} }
} }
static getHeatingTypeId(realEstateProperties) {
const realEstatePropertiesKeys = Object.keys(realEstateProperties);
for (const property of realEstatePropertiesKeys) {
switch (property) {
case "centralno toplane":
return HEATING_TYPE.CENTRAL_CITY.id;
case "etažno plinsko":
return HEATING_TYPE.CENTRAL_GAS.id;
case "termo blok":
case "podno grijanje":
return HEATING_TYPE.OTHER.id;
case "etažno električno":
case "konvektori":
return HEATING_TYPE.ELECTRICITY.id;
case "plinske peći":
return HEATING_TYPE.GAS.id;
case "vlastita kotlovnica":
return HEATING_TYPE.CENTRAL_BOILER.id;
case "toplotna pumpa":
return HEATING_TYPE.HEAT_PUMP.id;
case "kamin":
return HEATING_TYPE.WOOD.id;
default:
//console.log("[PROSTOR] Nepoznato >>> [", property, "]");
}
}
}
static getStatusId(statusText) {
switch (statusText) {
case "":
return AD_STATUS.STATUS_NORMAL;
case "Rezervisano":
return AD_STATUS.STATUS_RESERVED;
case "Prodano":
return AD_STATUS.STATUS_SOLD;
case "Iznajmljeno":
return AD_STATUS.STATUS_RENTED;
case "VIP ponuda":
return AD_STATUS.STATUS_VIP;
default:
console.log("[PROSTOR] Unknown AD_STATUS : [", statusText, "]");
return AD_STATUS.STATUS_NORMAL;
}
}
async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async saveCrawledResults(results) { async saveCrawledResults(results) {
const savers = this.savers; const savers = this.savers;
@@ -244,9 +583,54 @@ class ProstorCrawler {
// } // }
//For now, we use only Postgres saver, so ... //For now, we use only Postgres saver, so ...
return await savers[0].save(results); return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted //so that we can use some sequelize options and information when data is inserted
} }
async loginForScraping(PROSTOR_LOGIN, prostorCookie) {
let formData = new FormData();
formData.append("email", PROSTOR_LOGIN.EMAIL);
formData.append("password", PROSTOR_LOGIN.PASSWORD);
return fetch("https://prostor.ba/moj-prostor/prijava", {
method: "POST",
body: formData,
headers: { Cookie: prostorCookie }
})
.then(page => {
return page.text();
})
.then(resp => {
const $ = cheerio.load(resp);
if (
$("h1")
.text()
.indexOf("Dobrodošli") !== -1
) {
console.log("[PROSTOR]: Crawler loged in!");
return true;
} else {
console.log("[PROSTOR]: Crawler login failed - wrong credentials!");
return false;
}
})
.catch(err => {
console.log("[PROSTOR]: Crawler login error ", err);
});
}
async getCookies() {
const getResponse = await fetch("https://prostor.ba/moj-prostor/prijava", {
headers: { Cookie: "" }
});
const raw = getResponse.headers.raw()["set-cookie"];
const cookie = raw
.map(datastring => {
const data = datastring.split(";");
const cookieData = data[0];
return cookieData;
})
.join(";");
return cookie;
}
} }
module.exports = ProstorCrawler; module.exports = ProstorCrawler;

View File

@@ -1,6 +1,6 @@
"use strict"; "use strict";
const fetch = require("node-fetch"); const fetch = require("../../helpers/fetchWrapper");
const cheerio = require("cheerio"); const cheerio = require("cheerio");
const Promise = require("bluebird"); const Promise = require("bluebird");
const moment = require("moment-timezone"); const moment = require("moment-timezone");
@@ -11,7 +11,10 @@ const {
AD_CATEGORY, AD_CATEGORY,
AD_AGENCY, AD_AGENCY,
AD_STATUS, AD_STATUS,
CRAWLER_AD_TYPE CRAWLER_AD_TYPE,
HEATING_TYPE,
ACCESS_ROAD_TYPE,
FURNISHING_TYPE
} = require("../../common/enums"); } = require("../../common/enums");
const { const {
@@ -193,7 +196,7 @@ class RentalCrawler {
} }
async scrapeAd(url) { async scrapeAd(url) {
console.log("[RENTAL] Scraping : ", url); // console.log("[RENTAL] Scraping : ", url);
try { try {
const adPageSource = await fetch(url); const adPageSource = await fetch(url);
const body = await adPageSource.text(); const body = await adPageSource.text();
@@ -219,6 +222,7 @@ class RentalCrawler {
throw { message: "Can't find ad data JSON" }; throw { message: "Can't find ad data JSON" };
} }
let adStatus = AD_STATUS.STATUS_NORMAL;
const rentalId = extractedData["re_realEstates_id"]; const rentalId = extractedData["re_realEstates_id"];
const adCategory = this.getKiviCategoryIdFromRentalId( const adCategory = this.getKiviCategoryIdFromRentalId(
parseInt(extractedData["re_types_id"]) parseInt(extractedData["re_types_id"])
@@ -237,6 +241,143 @@ class RentalCrawler {
}; };
} }
const descriptionIds = extractedData["re_descriptions_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(descriptionIds)) {
throw {
message:
'Expected array od descriptions but "re_descriptions_id" not found !'
};
}
const spaceIds = extractedData["re_spaces_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(spaceIds)) {
throw {
message: 'Expected array od spaces but "re_spaces_id" not found !'
};
}
const infrastructureIds = extractedData["re_infrastructure_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(infrastructureIds)) {
throw {
message:
'Expected array od infrastructures but "re_infrastructure_id" not found !'
};
}
const floorNoIds = extractedData["re_floorNO_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(floorNoIds)) {
throw {
message:
'Expected array od infrastructures but "re_floorNO_id" not found !'
};
}
const numberOfViewsAgencySelector = $(
"body > div > div.container > div.row.content-top > div.col-xs-12.col-sm-12.col-md-9 > div > div.box-viewcount"
);
// number of views is written as : "Broj pregledavanja: NNN"
const numberOfViewsAgencyFullText = numberOfViewsAgencySelector
.text()
.trim();
const numberOfViewsAgencyParts = numberOfViewsAgencyFullText.split(":");
const realEstatePropertiesFromDescriptions = this.getPropertiesFromDescriptions(
descriptionIds
);
const realEstatePropertiesFromSpaces = this.getPropertiesFromSpaces(
spaceIds
);
const realEstatePropertiesFromInfrastructure = this.getPropertiesFromInfrastructure(
infrastructureIds
);
if (extractedData["adm_realEstates_discount"] === "1") {
adStatus = AD_STATUS.STATUS_DISCOUNTED;
}
let numberOfRooms =
parseInt(extractedData["re_realEstates_roomsNO"]) +
parseInt(extractedData["re_realEstates_bedNO"]) || null,
numberOfFloors =
parseInt(extractedData["re_realEstates_floorsNO"]) ||
this.getNumberOfFloorsFromFloorId(extractedData["re_floorNO_id"]),
floor =
parseInt(extractedData["re_realEstates_floorNO"]) ||
this.getFloorNumberFromFloorId(extractedData["re_floorNO_id"]),
accessRoadType = realEstatePropertiesFromDescriptions.accessRoadType,
heatingType =
this.getHeatingTypeId(extractedData["re_heating_id"]) || null,
furnishingType = realEstatePropertiesFromDescriptions.furnishingType,
balcony =
realEstatePropertiesFromDescriptions.balcony ||
realEstatePropertiesFromSpaces.balcony,
newBuilding = extractedData["op_realEstates_newBuilding"]
? extractedData["op_realEstates_newBuilding"] === "1"
: null,
elevator = realEstatePropertiesFromDescriptions.elevator,
water =
realEstatePropertiesFromDescriptions.water ||
realEstatePropertiesFromInfrastructure.water,
electricity =
realEstatePropertiesFromDescriptions.electricity ||
realEstatePropertiesFromInfrastructure.electricity,
drainageSystem =
realEstatePropertiesFromInfrastructure.drainageSystem,
registeredInZkBooks =
extractedData["op_realEstates_ownerPermit"] === 1 || null,
recentlyAdapted = null,
parking =
realEstatePropertiesFromDescriptions.parking ||
realEstatePropertiesFromSpaces.parking,
garage = realEstatePropertiesFromSpaces.garage,
gas = realEstatePropertiesFromInfrastructure.gas,
antiTheftDoor = realEstatePropertiesFromDescriptions.antiTheftDoor,
airCondition = realEstatePropertiesFromDescriptions.airCondition,
phoneConnection =
realEstatePropertiesFromInfrastructure.phoneConnection,
cableTV = realEstatePropertiesFromInfrastructure.cableTV,
internet = realEstatePropertiesFromInfrastructure.internet,
basementAttic =
realEstatePropertiesFromSpaces.basementAttic ||
this.checkBasemAtticFromFloors(extractedData["re_floorNO_id"]),
storeRoom = realEstatePropertiesFromSpaces.storeRoom,
videoSurveillance =
realEstatePropertiesFromDescriptions.videoSurveillance ||
realEstatePropertiesFromInfrastructure.videoSurveillance,
alarm = realEstatePropertiesFromDescriptions.alarm,
suitableForStudents = null,
includingBills =
extractedData["op_realEstates_utilitiesIncluded"] === "1" || null,
animalsAllowed = null,
pool = realEstatePropertiesFromDescriptions.pool,
urbanPlanPermit =
extractedData["op_realEstates_locationPermit"] === "1" ||
realEstatePropertiesFromDescriptions.urbanPlanPermit,
buildingPermit =
extractedData["op_realEstates_buildingPermit"] === "1" || null,
utilityConnection =
realEstatePropertiesFromDescriptions.utilityConnection,
distanceToRiver = null,
numberOfViewsAgency =
numberOfViewsAgencyParts.length > 1
? parseInt(numberOfViewsAgencyParts[1])
: null;
const title = extractedData["re_realEstates_portalName"]; const title = extractedData["re_realEstates_portalName"];
const extractedPrice = parseFloat( const extractedPrice = parseFloat(
extractedData["re_realEstates_price"] extractedData["re_realEstates_price"]
@@ -277,8 +418,6 @@ class RentalCrawler {
}; };
} }
const adStatus = AD_STATUS.STATUS_NORMAL;
const data = { const data = {
url, url,
agencyObjectId: rentalId, agencyObjectId: rentalId,
@@ -303,7 +442,42 @@ class RentalCrawler {
locationLong, locationLong,
adStatus, adStatus,
publishedDate: publishedDateMoment.toISOString(), publishedDate: publishedDateMoment.toISOString(),
renewedDate: renewedDateMoment.toISOString() renewedDate: renewedDateMoment.toISOString(),
numberOfRooms,
numberOfFloors,
floor,
accessRoadType,
heatingType,
furnishingType,
balcony,
newBuilding,
elevator,
water,
electricity,
drainageSystem,
registeredInZkBooks,
recentlyAdapted,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
urbanPlanPermit,
buildingPermit,
utilityConnection,
distanceToRiver,
numberOfViewsAgency
}; };
return data; return data;
@@ -350,6 +524,304 @@ class RentalCrawler {
} }
} }
getPropertiesFromDescriptions(descriptionIds) {
const result = {
accessRoadType: null,
furnishingType: null,
balcony: null,
elevator: null,
parking: null,
antiTheftDoor: null,
airCondition: null,
videoSurveillance: null,
alarm: null,
pool: null,
urbanPlanPermit: null,
utilityConnection: null,
water: null,
electricity: null
};
for (const descriptionId of descriptionIds) {
switch (descriptionId) {
case 16:
result.furnishingType = FURNISHING_TYPE.NOT_FURNISHED.id;
break;
case 17:
result.furnishingType = FURNISHING_TYPE.HALF_FURNISHED.id;
break;
case 1:
case 28:
result.furnishingType = FURNISHING_TYPE.FURNISHED.id;
break;
case 14:
result.elevator = true;
break;
case 39:
result.electricity = true;
break;
case 40:
result.water = true;
break;
case 41:
case 58:
result.accessRoadType = ACCESS_ROAD_TYPE.ASPHALT.id;
break;
case 26:
result.balcony = true;
break;
case 62:
result.parking = true;
break;
case 3:
result.antiTheftDoor = true;
break;
case 2:
case 21:
result.airCondition = true;
break;
case 4:
result.alarm = true;
break;
case 55:
result.videoSurveillance = true;
break;
case 9:
result.pool = true;
break;
case 60:
result.urbanPlanPermit = true;
break;
case 38:
result.utilityConnection = true;
break;
}
}
return result;
}
getPropertiesFromSpaces(spaceIds) {
const result = {
balcony: null,
parking: null,
garage: null,
basementAttic: null,
storeRoom: null
};
for (const spaceId of spaceIds) {
switch (spaceId) {
case 36:
case 12:
result.parking = true;
break;
case 1:
case 2:
case 3:
result.balcony = true;
break;
case 4:
case 30:
result.garage = true;
break;
case 9:
case 10:
result.storeRoom = true;
break;
case 18:
case 34:
case 37:
case 27:
result.basementAttic = true;
break;
}
}
return result;
}
getHeatingTypeId(heatingRentalId) {
// heatingRentalId can have multiple values, like: "1, 2, 3", parseInt will take first integer value
const heatingId = parseInt(heatingRentalId);
switch (heatingId) {
case 27:
case 16:
return HEATING_TYPE.GAS.id;
case 4:
return HEATING_TYPE.CENTRAL_GAS.id;
case 3:
case 23:
case 6:
case 7:
case 8:
case 9:
case 10:
return HEATING_TYPE.CENTRAL_BOILER.id;
case 2:
case 13:
case 30:
case 17:
case 29:
case 31:
return HEATING_TYPE.ELECTRICITY.id;
case 24:
case 25:
case 12:
return HEATING_TYPE.CENTRAL_CITY.id;
case 26:
case 21:
case 20:
return HEATING_TYPE.WOOD.id;
case 28:
case 19:
return HEATING_TYPE.HEAT_PUMP.id;
case 14:
case 32:
return HEATING_TYPE.OTHER.id;
default:
return null;
}
}
getPropertiesFromInfrastructure(infrastructureIds) {
const result = {
electricity: null,
water: null,
gas: null,
drainageSystem: null,
phoneConnection: null,
internet: null,
videoSurveillance: null,
cableTV: null
};
for (const infrastructureId of infrastructureIds) {
switch (infrastructureId) {
case 1:
result.electricity = true;
break;
case 2:
result.water = true;
break;
case 4:
result.gas = true;
break;
case 5:
result.drainageSystem = true;
break;
case 7:
case 8:
result.phoneConnection = true;
break;
case 10:
result.internet = true;
break;
case 11:
result.cableTV = true;
break;
case 16:
case 17:
result.videoSurveillance = true;
break;
}
}
return result;
}
getFloorNumberFromFloorId(floorsIdText) {
// floorIdText can be array of numbers, separated by comma or number
// just extracting floor number from first element
const floorsId = floorsIdText.split(",");
if (floorsId.length === 0) {
return null;
}
const firstFloorId = parseInt(floorsId[0]);
// 1 pod
// 2 sut
// 3 raz
// 4 pri
// 5 vpri
// 6 prv
// 7 dru
// 8 tre
// 9 čet
// 10 man
// 11
// 12 pot
// 13 vpot
// 14 tav
// 15 pet
const floorNumber = [
-1,
-1,
0,
0,
1,
1,
2,
3,
4,
null,
null,
null,
null,
null,
5
];
return floorNumber[firstFloorId - 1] || null;
}
getNumberOfFloorsFromFloorId(floorsIdText) {
// floorIdText can be array of numbers, separated by comma or number
const floorIds = floorsIdText.split(",");
if (floorIds.length === 0) {
return null;
}
let noOfFloors = floorIds.length;
// Floors of 'suteren', 'podrum', 'tavan' and 'potkrovlje' are not counted
floorIds.forEach(id => {
if (
parseInt(id) === 1 ||
parseInt(id) === 2 ||
parseInt(id) === 12 ||
parseInt(id) === 14
) {
noOfFloors--;
}
});
return noOfFloors;
}
checkBasemAtticFromFloors(floorsIdText) {
// floorIdText can be array of numbers, separated by comma or number
const floorIds = floorsIdText.split(",");
let check = false;
if (floorIds.length === 0) {
check = false;
}
//If floors 'suteren', 'podrum', 'tavan' and 'potkrovlje' exists then tag for basement-attic is true
floorIds.forEach(id => {
if (
parseInt(id) === 1 ||
parseInt(id) === 2 ||
parseInt(id) === 12 ||
parseInt(id) === 14
) {
check = true;
}
});
return check;
}
async sleep(ms) { async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
@@ -362,7 +834,7 @@ class RentalCrawler {
// } // }
//For now, we use only Postgres saver, so ... //For now, we use only Postgres saver, so ...
return await savers[0].save(results); return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted //so that we can use some sequelize options and information when data is inserted
} }
} }

View File

@@ -0,0 +1,630 @@
"use strict";
const fetch = require("../../helpers/fetchWrapper");
const cheerio = require("cheerio");
const moment = require("moment-timezone");
const {
AD_TYPE,
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE,
FURNISHING_TYPE,
HEATING_TYPE
} = require("../../common/enums");
const {
PRINT_CRAWLER_DEBUG,
DEFAULT_TIMEZONE
} = require("../../config/appConfig");
const { SALJIC_FORCE_CRAWL } = require("../specificConfigs/saljic");
const SALJIC_ENUMS = {
SALJIC_AD_TYPE: {
[CRAWLER_AD_TYPE.ALL]: "&input_vrsta=",
[CRAWLER_AD_TYPE.ONLY_SELL]: "&input_vrsta=1",
[CRAWLER_AD_TYPE.ONLY_RENT]: "&input_vrsta=2"
},
SALJIC_AD_CATEGORY: {
[AD_CATEGORY.ALL.id]: "&input_kategorija=",
[AD_CATEGORY.FLAT.id]: "&input_kategorija=15",
[AD_CATEGORY.HOUSE.id]: "&input_kategorija=9",
[AD_CATEGORY.LAND.id]: "&input_kategorija=5", //3 and 4 also gradjevinsko
[AD_CATEGORY.OFFICE.id]: "&input_kategorija=8",
[AD_CATEGORY.APARTMENT.id]: "&input_kategorija=1",
[AD_CATEGORY.GARAGE.id]: "&input_kategorija=2"
//[AD_CATEGORY.COTTAGE.id]: ""
}
};
class SaljicCrawler {
constructor(
savers = [],
crawlerAdTypes = CRAWLER_AD_TYPE.ALL,
crawlerAdCategories = [AD_CATEGORY.FLAT, AD_CATEGORY.HOUSE],
maxPages = 5000,
maxResultsPerPage = 5000,
ignoredUsernames = [],
delayBetweenPages = 1000
) {
this.savers = savers;
this.baseUrl = "https://www.saljicnekretnine.ba/v2/nekretnine_search";
this.crawlerAdTypes = crawlerAdTypes;
this.crawlerAdCategories = crawlerAdCategories;
this.maxResultsPerPage = maxResultsPerPage;
this.delayBetweenPages = delayBetweenPages;
}
async crawl() {
const crawlAdCategories = this.crawlerAdCategories;
const newRealEstates = [];
if (crawlAdCategories) {
const indexGenerators = [];
for (const adCategory of crawlAdCategories) {
indexGenerators.push(this.categoryIndexer(adCategory));
}
//
//console.log(indexGenerators);
//
let done = false;
while (!done) {
const categoryIndexerPromises = [];
const generatorsToRemove = [];
for (const indexGenerator of indexGenerators) {
categoryIndexerPromises.push(indexGenerator.next());
generatorsToRemove.push(false);
}
const singlePageResults = await Promise.all(categoryIndexerPromises);
const entries = singlePageResults.entries();
for (const [index, { value: singlePageResult }] of entries) {
if (singlePageResult) {
const saveResults = await this.saveCrawledResults(singlePageResult);
const { newRecords } = saveResults;
newRealEstates.push(...newRecords);
if (
Array.isArray(newRecords) &&
newRecords.length === 0 &&
!SALJIC_FORCE_CRAWL
) {
generatorsToRemove[index] = true;
}
} else {
//Generator returned undefined, remove this generator from array
generatorsToRemove[index] = true;
// console.log("Generator ", index + 1, "has no more pages");
}
}
// console.log("Generators state : ", generatorsToRemove);
for (let i = generatorsToRemove.length - 1; i >= 0; i--) {
if (generatorsToRemove[i]) {
// console.log("\tRemove generator ", i + 1);
indexGenerators.splice(i, 1);
}
}
if (indexGenerators.length === 0) {
done = true;
}
await this.sleep(this.delayBetweenPages);
}
}
return newRealEstates;
}
async *categoryIndexer(adCategory) {
let pageToIndex = 1;
const urlAdTypePart = SALJIC_ENUMS.SALJIC_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = SALJIC_ENUMS.SALJIC_AD_CATEGORY[adCategory];
if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) {
while (true) {
const urlPagePart = pageToIndex === 1 ? "" : (pageToIndex - 1) * 2 * 11;
const urlPageToCrawl = `${this.baseUrl}?order_by=${urlAdTypePart}${urlCategoryPart}&per_page=${urlPagePart}`;
const singlePageResults = await this.indexSinglePage(
urlPageToCrawl,
this.maxResultsPerPage
);
if (Array.isArray(singlePageResults) && singlePageResults.length > 0) {
yield singlePageResults;
} else {
return undefined;
}
++pageToIndex;
if (pageToIndex === this.maxPages) {
return undefined;
}
}
} else {
return undefined;
}
}
async indexSinglePage(url, maxResultsPerPage) {
if (PRINT_CRAWLER_DEBUG) {
console.log("[SALJIC] Index page : ", url);
}
try {
const res = await fetch(url);
const body = await res.text();
const $ = cheerio.load(body);
let hrefs = [];
$("#shop")
.find(".product")
.each((i, elem) => {
const href = $(elem)
.find("a")
.first()
.attr("href");
if (href) {
hrefs.push(href);
}
});
let adTypesTmp = [];
$("#shop")
.find(".product")
.each((i, elem) => {
const adType = $(elem)
.find(".trakica-search-page")
.text()
.trim();
if (adType) {
adTypesTmp.push(adType);
}
});
//Converting to AD_TYPE
const adTypes = adTypesTmp.map(adTypeText => {
return this.getAdTypeId(adTypeText);
});
//Converting to absolute URLs
const hrefsAbs = hrefs.map(link => {
return "https://www.saljicnekretnine.ba" + link;
});
let actualNoOfResults =
hrefsAbs.length <= maxResultsPerPage
? hrefsAbs.length
: maxResultsPerPage;
const asyncScraping = [];
for (let i = 0; i < actualNoOfResults; i++) {
asyncScraping.push(this.scrapeAd(hrefsAbs[i], adTypes[i]));
}
const scrapedData = await Promise.all(asyncScraping);
const filteredScrapedData = scrapedData.filter(adData => !!adData);
return filteredScrapedData;
} catch (e) {
console.error("[SALJIC] Exception caught:" + e);
return [];
}
}
async scrapeAd(url, adType) {
// console.log("[SALJIC] Scraping : ", url);
try {
const adPageSource = await fetch(url);
const body = await adPageSource.text();
const $ = cheerio.load(body);
// No information for status ex. PRODAN
const status = AD_STATUS.STATUS_NORMAL;
//Extracting agency ID from url
const agencyObjectId = parseInt(url.substring(46, url.length));
//Extracting main properties
const propertySelectors = {
title:
"div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-title > h2",
price:
"div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.topmargin-sm.single-product > div.product > div.product-price > ins",
streetName:
"div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-content.topmargin > p",
descriptions:
"div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-content.topmargin > div.toggle.toggle-bg > div.togglec >p:nth-child(1)",
latAndLong:
"div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-content.topmargin > div.gmap.bottommargin > iframe"
};
const title = $(propertySelectors.title)
.text()
.replace(/(\r\n|\n|\r)/gm, "")
.replace(/ {1,}/g, " ")
.trim();
const priceText = $(propertySelectors.price)
.text()
.replace(/(\r\n|\n|\r)/gm, "")
.replace(/ {1,}/g, " ")
.trim();
const price =
priceText === "CIJENA NA UPIT"
? null
: parseFloat(
priceText.substring(8, priceText.length - 3).replace(",", "")
);
const streetName = $(propertySelectors.streetName)
.text()
.replace(/(\r\n|\n|\r)/gm, "")
.trim();
const descriptions = $(propertySelectors.descriptions)
.text()
.replace(/\"/g, "")
.trim();
const latAndLongSrc = $(propertySelectors.latAndLong).attr("src");
const latText = latAndLongSrc.substring(
latAndLongSrc.indexOf("marker=") + 7,
latAndLongSrc.indexOf("%2C", latAndLongSrc.indexOf("marker="))
);
const longText = latAndLongSrc.substring(
latAndLongSrc.indexOf("%2C", latAndLongSrc.indexOf("marker=")) + 3,
latAndLongSrc.length
);
const locationLat = parseFloat(latText) || null;
const locationLong = parseFloat(longText) || null;
//====== DETAIL INFORMATION FIELDS ==========
let area = null,
gardenSize = null,
numberOfRooms = null,
numberOfFloors = null,
floor = null,
accessRoadType = null,
heatingType = null,
furnishingType = null,
balcony = null,
newBuilding = null,
elevator = null,
water = null,
electricity = null,
drainageSystem = null,
registeredInZkBooks = null,
recentlyAdapted = null,
parking = null,
garage = null,
gas = null,
antiTheftDoor = null,
airCondition = null,
phoneConnection = null,
cableTV = null,
internet = null,
basementAttic = null,
storeRoom = null,
videoSurveillance = null,
alarm = null,
suitableForStudents = null,
includingBills = null,
animalsAllowed = null,
pool = null,
exchange = null,
urbanPlanPermit = null,
buildingPermit = null,
utilityConnection = null,
distanceToRiver = null;
let publishedDate = null;
let renewedDate = null;
let realEstateType;
let numberOfViewsAgency = null;
let numberOfViewsKivi = null;
let streetNumber = 0;
let adStatus = status;
let shortDescription = descriptions.substring(
0,
descriptions.indexOf(".")
);
let longDescription = descriptions;
//Extracting data - Glavne karakteristike
let mainFieldIndex = 1;
do {
const mainFieldSelector = `div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-content.topmargin > div.col-md-12.bottommargin > ul > li.list-group-item:nth-child(${mainFieldIndex})`;
const mainField = $(mainFieldSelector)
.text()
.replace(/[\n\r\t]/gm, "")
.trim();
const mainFieldTitle = mainField.substring(0, mainField.indexOf(" "));
const mainFieldValue = mainField
.substring(mainField.indexOf(" "), mainField.length)
.trim();
switch (mainFieldTitle) {
case "Površina":
area = parseFloat(
mainFieldValue.substring(0, mainFieldValue.indexOf(" "))
);
break;
case "Okućnica":
gardenSize = parseFloat(
mainFieldValue.substring(0, mainFieldValue.indexOf(" "))
);
break;
case "Broj soba":
numberOfRooms = parseInt(mainFieldValue);
break;
case "Broj spratova":
numberOfFloors = parseInt(mainFieldValue);
break;
case "Sprat":
floor = parseInt(mainFieldValue);
break;
case "Godina renoviranja":
recentlyAdapted = true;
break;
case "Broj parking mjesta":
parking = true;
break;
case "Dostupno od":
const day = mainFieldValue.substring(0, 2);
const month = mainFieldValue.substring(3, 5);
const year = mainFieldValue.substring(6, mainFieldValue.length);
publishedDate = new Date(`${month}/${day}/${year}`);
break;
default:
break;
}
if (mainFieldTitle === "") {
break;
}
mainFieldIndex++;
} while (true);
//Extracting data - Sadrzaji
let additionalFieldIndex = 1;
do {
const additionalFieldSelector = `div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-content.topmargin > div.col-md-12.bottommargin > ul > li.border-color.col-md-5.col-md-offset-1.col-md-pull-1.list-group-item-bottom:nth-child(${additionalFieldIndex})`;
const additionalField = $(additionalFieldSelector)
.text()
.trim();
if (additionalFieldIndex === 1) {
//Extracting data of real estate type
const categoryTmp = additionalField
.replace(/[\n\r\t]/gm, "")
.substring(
additionalField.indexOf("Kategorija") + 10,
additionalField.length
)
.trim();
realEstateType = this.getAdCategoryId(categoryTmp);
} else {
switch (additionalField) {
case "Internet":
internet = true;
break;
case "Garaža":
garage = true;
break;
case "Klima":
airCondition = true;
break;
case "Balkon":
balcony = true;
break;
case "Ostava":
storeRoom = true;
break;
case "Podrum":
basementAttic = true;
break;
case "Blindirana vrata":
antiTheftDoor = true;
break;
case "Voda":
water = true;
break;
case "Kablovska":
cableTV = true;
break;
case "Uknjiženo":
registeredInZkBooks = true;
break;
case "Grijanje - centralno":
heatingType = HEATING_TYPE.CENTRAL_CITY.id;
break;
case "Grijanje - plin":
heatingType = HEATING_TYPE.GAS.id;
break;
case "Grijanje - struja":
heatingType = HEATING_TYPE.ELECTRICITY.id;
break;
case "Grijanje":
heatingType = HEATING_TYPE.OTHER.id;
break;
case "Plin":
gas = true;
break;
case "Namješten":
furnishingType = FURNISHING_TYPE.FURNISHED.id;
break;
case "Alarm":
alarm = true;
break;
case "Video nadzor":
videoSurveillance = true;
break;
case "Lift":
elevator = true;
break;
case "Novogradnja":
newBuilding = true;
break;
default:
break;
}
}
if (additionalField === "") {
break;
}
additionalFieldIndex++;
} while (true);
//If no published date it takes current date of crawling
if (publishedDate) {
renewedDate = new Date();
} else {
publishedDate = new Date();
renewedDate = new Date();
}
const originAgencyName = AD_AGENCY.SALJIC;
const locality = "";
const municipality = "";
const city = "";
const region = "";
const entity = "";
const country = "";
const data = {
url,
agencyObjectId,
originAgencyName,
realEstateType,
adType,
title,
price,
area,
gardenSize,
shortDescription,
longDescription,
streetNumber,
streetName,
locality,
municipality,
city,
region,
entity,
country,
locationLat,
locationLong,
adStatus,
publishedDate,
renewedDate,
numberOfRooms,
numberOfFloors,
floor,
accessRoadType,
heatingType,
furnishingType,
balcony,
newBuilding,
elevator,
water,
electricity,
drainageSystem,
registeredInZkBooks,
recentlyAdapted,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
exchange,
urbanPlanPermit,
buildingPermit,
utilityConnection,
distanceToRiver,
numberOfViewsAgency,
numberOfViewsKivi
};
return data;
} catch (e) {
console.error("Exception caught: " + e.message, "\r\nURL:", url);
}
return null;
}
//======= HELPER FUNCTIONS =============
getAdCategoryId(categoryText) {
switch (categoryText) {
case "Stan":
return AD_CATEGORY.FLAT.id;
case "Građevinsko zemljiste":
return AD_CATEGORY.LAND.id;
case "Industrijsko zemljiste":
return AD_CATEGORY.LAND.id;
case "Poljoprivredno zemljiste":
return AD_CATEGORY.LAND.id;
case "Kuća":
return AD_CATEGORY.HOUSE.id;
case "Poslovni prostor":
return AD_CATEGORY.OFFICE.id;
case "Kancelarije":
return AD_CATEGORY.OFFICE.id;
case "Apartmani":
return AD_CATEGORY.APARTMENT.id;
case "Garaža":
return AD_CATEGORY.GARAGE.id;
case "Vikendica":
return AD_CATEGORY.COTTAGE.id;
default:
return undefined;
}
}
getAdTypeId(adTypeText) {
switch (adTypeText) {
case "PRODAJA":
return AD_TYPE.AD_TYPE_SALE.stringId;
case "NAJAM":
return AD_TYPE.AD_TYPE_RENT.stringId;
default:
return undefined;
}
}
async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async saveCrawledResults(results) {
const savers = this.savers;
// for (const saver of savers) {
// await saver.save(results);
// }
//For now, we use only Postgres saver, so ...
return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
}
module.exports = SaljicCrawler;

View File

@@ -0,0 +1,20 @@
"use strict";
const db = require("../../models/index");
const sequelize = require("sequelize");
const bulkUpsertPriceHistory = async priceHistoryData => {
try {
const order = [["realEstateId", "desc"]];
return await db.PriceHistory.bulkCreate(priceHistoryData, {
order,
ignoreDuplicates: true
});
} catch (e) {
console.log("Error bulk upserting priceHistory : ", e);
}
};
module.exports = {
bulkUpsertPriceHistory
};

View File

@@ -2,6 +2,7 @@
const db = require("../../models/index"); const db = require("../../models/index");
const sequelize = require("sequelize"); const sequelize = require("sequelize");
const Op = sequelize.Op; const Op = sequelize.Op;
const { AD_CATEGORY } = require("../../common/enums");
const bulkUpsertRealEstates = async realEstateData => { const bulkUpsertRealEstates = async realEstateData => {
try { try {
@@ -26,7 +27,42 @@ const bulkUpsertRealEstates = async realEstateData => {
"gardenSize", "gardenSize",
"adStatus", "adStatus",
"updatedAt", "updatedAt",
"renewedDate" "renewedDate",
"numberOfRooms",
"numberOfFloors",
"floor",
"accessRoadType",
"heatingType",
"furnishingType",
"balcony",
"newBuilding",
"elevator",
"water",
"electricity",
"drainageSystem",
"registeredInZkBooks",
"recentlyAdapted",
"parking",
"garage",
"gas",
"antiTheftDoor",
"airCondition",
"phoneConnection",
"cableTV",
"internet",
"basementAttic",
"storeRoom",
"videoSurveillance",
"alarm",
"suitableForStudents",
"includingBills",
"animalsAllowed",
"pool",
"urbanPlanPermit",
"buildingPermit",
"utilityConnection",
"distanceToRiver",
"numberOfViewsAgency"
]; ];
const order = [["updatedAt", "desc"]]; const order = [["updatedAt", "desc"]];
@@ -52,9 +88,26 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
sizeMax, sizeMax,
adType, adType,
realEstateType, realEstateType,
areaToSearch areaToSearch,
gardenSizeMin,
gardenSizeMax,
numberOfRoomsMin,
numberOfRoomsMax,
numberOfFloorsMin,
numberOfFloorsMax,
floorMin,
floorMax,
includeIncompleteAds,
includeWithoutPrice,
balcony,
elevator,
newBuilding,
accessRoadType
} = searchRequest; } = searchRequest;
//Needed for defining which attribute should exist or not
const realEstateTypeObject = AD_CATEGORY[realEstateType];
const longitudeColumn = sequelize.col("locationLong"); const longitudeColumn = sequelize.col("locationLong");
const latitudeColumn = sequelize.col("locationLat"); const latitudeColumn = sequelize.col("locationLat");
@@ -81,13 +134,12 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
const geoSearchQueryPart = sequelize.where(contains, true); const geoSearchQueryPart = sequelize.where(contains, true);
//General queries contain only attributes that are defined for every searchreq
//Query for case of complete ads
const query = { const query = {
adType, adType,
realEstateType, realEstateType,
price: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
area: { area: {
[Op.lte]: sizeMax, [Op.lte]: sizeMax,
[Op.gte]: sizeMin [Op.gte]: sizeMin
@@ -95,10 +147,199 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
[Op.and]: geoSearchQueryPart [Op.and]: geoSearchQueryPart
}; };
//Query for case of incomplete ads
const queryIncludeIncomplete = {
adType,
realEstateType,
area: {
[Op.or]: {
[Op.and]: {
[Op.lte]: sizeMax,
[Op.gte]: sizeMin
},
[Op.is]: null
}
},
[Op.and]: geoSearchQueryPart
};
//Is user unchecked includeWithoutPrice FALSE then it shouldn't return null values of price
//If not then null values are accepted (this is DEFAULT)
//includeIncpompleteAds does not have effect on price query
if (includeWithoutPrice) {
query.price = {
[Op.or]: {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
[Op.is]: null
}
};
queryIncludeIncomplete.price = {
[Op.or]: {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
[Op.is]: null
}
};
} else {
query.price = {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
}
};
queryIncludeIncomplete.price = {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
}
};
}
//Every other attribute is checked separately and included in query only if it is defined for real estate type
if (
realEstateTypeObject.hasGardenSize &&
gardenSizeMax != null &&
gardenSizeMin != null
) {
query.gardenSize = {
[Op.lte]: gardenSizeMax,
[Op.gte]: gardenSizeMin
};
queryIncludeIncomplete.gardenSize = {
[Op.or]: {
[Op.and]: {
[Op.lte]: gardenSizeMax,
[Op.gte]: gardenSizeMin
},
[Op.is]: null
}
};
}
if (
realEstateTypeObject.hasNumberOfRoom &&
numberOfRoomsMin != null &&
numberOfRoomsMax != null
) {
query.numberOfRooms = {
[Op.lte]: numberOfRoomsMax,
[Op.gte]: numberOfRoomsMin
};
queryIncludeIncomplete.numberOfRooms = {
[Op.or]: {
[Op.and]: {
[Op.lte]: numberOfRoomsMax,
[Op.gte]: numberOfRoomsMin
},
[Op.is]: null
}
};
}
if (
realEstateTypeObject.hasNumberOfFloors &&
numberOfFloorsMin != null &&
numberOfFloorsMax != null
) {
query.numberOfFloors = {
[Op.lte]: numberOfFloorsMax,
[Op.gte]: numberOfFloorsMin
};
queryIncludeIncomplete.numberOfFloors = {
[Op.or]: {
[Op.and]: {
[Op.lte]: numberOfFloorsMax,
[Op.gte]: numberOfFloorsMin
},
[Op.is]: null
}
};
}
if (
realEstateTypeObject.hasFloorProp &&
floorMin != null &&
floorMax != null
) {
query.floor = {
[Op.lte]: floorMax,
[Op.gte]: floorMin
};
queryIncludeIncomplete.floor = {
[Op.or]: {
[Op.and]: {
[Op.lte]: floorMax,
[Op.gte]: floorMin
},
[Op.is]: null
}
};
}
//Logic for balcony, newBuilding and elevator from users side
//If true is checked, then I want characteristic to be true but,
//if it is not checked, then I dont care - it can be null or false or true
if (realEstateTypeObject.hasBalconyProp && balcony === true) {
query.balcony = {
[Op.eq]: balcony
};
queryIncludeIncomplete.balcony = {
[Op.or]: {
[Op.eq]: balcony,
[Op.is]: null
}
};
}
if (realEstateTypeObject.hasNewBuildingProp && newBuilding === true) {
query.newBuilding = {
[Op.eq]: newBuilding
};
queryIncludeIncomplete.newBuilding = {
[Op.or]: {
[Op.eq]: newBuilding,
[Op.is]: null
}
};
}
if (realEstateTypeObject.hasElevatorProp && elevator === true) {
query.elevator = {
[Op.eq]: elevator
};
queryIncludeIncomplete.elevator = {
[Op.or]: {
[Op.eq]: elevator,
[Op.is]: null
}
};
}
//If user wants 'ANY' road type acces then it is not included in query -
//returns every road type and null values
if (accessRoadType !== "ANY") {
query.accessRoadType = {
[Op.eq]: accessRoadType
};
queryIncludeIncomplete.accessRoadType = {
[Op.or]: {
[Op.eq]: accessRoadType,
[Op.is]: null
}
};
}
//When includeIncompleteAds are not defined - null it will consider it true
const order = [["updatedAt", "desc"]]; const order = [["updatedAt", "desc"]];
return await db.RealEstate.findAll({ return db.RealEstate.findAll({
where: query, where:
includeIncompleteAds || includeIncompleteAds == null
? queryIncludeIncomplete
: query,
limit: maxResults, limit: maxResults,
order order
}); });

View File

@@ -2,11 +2,13 @@
const db = require("../../models/index"); const db = require("../../models/index");
const sequelize = require("sequelize"); const sequelize = require("sequelize");
const Op = sequelize.Op; const Op = sequelize.Op;
const { AD_CATEGORY } = require("../../common/enums");
const getSearchRequest = async searchRequestId => { const getSearchRequest = async searchRequestId => {
try { try {
return await db.SearchRequest.findByPk(searchRequestId); return await db.SearchRequest.findByPk(searchRequestId);
} catch (error) { } catch (error) {
console.log("searchrequest.js", error);
return null; return null;
} }
}; };
@@ -22,7 +24,15 @@ const findSearchRequestsForRealEstate = async realEstate => {
adType, adType,
realEstateType, realEstateType,
locationLat, locationLat,
locationLong locationLong,
accessRoadType,
balcony,
newBuilding,
elevator,
gardenSize,
numberOfRooms,
numberOfFloors,
floor
} = realEstate; } = realEstate;
if (!locationLat || !locationLong) { if (!locationLat || !locationLong) {
@@ -39,32 +49,411 @@ const findSearchRequestsForRealEstate = async realEstate => {
const geoSearchQueryPart = sequelize.where(contains, true); const geoSearchQueryPart = sequelize.where(contains, true);
//Needed for defining which attribute should exist or not
const realEstateTypeObject = AD_CATEGORY[realEstateType];
// ?? Needed to decide on including incomplete RealEstates data
let checkForIncompleteWanted = false;
//Attributes are checked separately to make different query parts
//If real estate price is number then it searches for req that have priceMin and priceMax
//If real estate price is null it searches for req that accept ads without price
//User always defines price and area (sliders) - not null in search req
let priceQuery = {};
if (price != null) {
priceQuery = {
[Op.and]: [
{
priceMin: {
[Op.lte]: price
}
},
{
priceMax: {
[Op.gte]: price
}
}
]
};
} else {
priceQuery = {
includeWithoutPrice: {
[Op.eq]: true
}
};
}
let areaQuery = {};
if (area != null) {
areaQuery = {
[Op.and]: [
{
sizeMin: {
[Op.lte]: area
}
},
{
sizeMax: {
[Op.gte]: area
}
}
]
};
} else {
checkForIncompleteWanted = true;
}
//Other attributes can be defined or not depending on RealEstate type
//we check what to include in query based on real estate type object
let gardenSizeQuery = {};
if (realEstateTypeObject.hasGardenSize) {
if (gardenSize != null) {
gardenSizeQuery = {
[Op.and]: [
{
gardenSizeMin: {
[Op.lte]: gardenSize
}
},
{
gardenSizeMax: {
[Op.gte]: gardenSize
}
}
]
};
} else {
checkForIncompleteWanted = true;
}
}
let numberOfRoomsQuery = {};
if (realEstateTypeObject.hasNumberOfRoom) {
if (numberOfRooms != null) {
//If real estate has defined number of rooms ex. 3 it returns req
// that accepts 3 rooms or ones that don't have defined number - null
//Ex. they didnt choose advanced filters at all
numberOfRoomsQuery = {
[Op.and]: [
{
numberOfRoomsMin: {
[Op.or]: {
[Op.lte]: numberOfRooms,
[Op.is]: null
}
}
},
{
numberOfRoomsMax: {
[Op.or]: {
[Op.gte]: numberOfRooms,
[Op.is]: null
}
}
}
]
};
} else {
// If real estate dont have defined number of rooms ex. null
//It returns requests that didn't choose number of rooms - also null
//Or ones that picked some values but also picked to includeIncomplete ads (or default)
numberOfRoomsQuery = {
[Op.or]: [
{
[Op.and]: [
{
numberOfRoomsMin: {
[Op.is]: null
}
},
{
numberOfRoomsMax: {
[Op.is]: null
}
}
]
},
{
includeIncompleteAds: {
[Op.or]: {
[Op.eq]: true,
[Op.is]: null
}
}
}
]
};
}
}
//Same logic for number of Floors and floors
let numberOfFloorsQuery = {};
if (realEstateTypeObject.hasNumberOfFloors) {
if (numberOfFloors != null) {
numberOfFloorsQuery = {
[Op.and]: [
{
numberOfFloorsMin: {
[Op.or]: {
[Op.lte]: numberOfFloors,
[Op.is]: null
}
}
},
{
numberOfFloorsMax: {
[Op.or]: {
[Op.gte]: numberOfFloors,
[Op.is]: null
}
}
}
]
};
} else {
numberOfFloorsQuery = {
[Op.or]: [
{
[Op.and]: [
{
numberOfFloorsMin: {
[Op.is]: null
}
},
{
numberOfFloorsMax: {
[Op.is]: null
}
}
]
},
{
includeIncompleteAds: {
[Op.or]: {
[Op.eq]: true,
[Op.is]: null
}
}
}
]
};
}
}
let floorQuery = {};
if (realEstateTypeObject.hasFloorProp) {
if (floor != null) {
floorQuery = {
[Op.and]: [
{
floorMin: {
[Op.or]: {
[Op.lte]: floor,
[Op.is]: null
}
}
},
{
floorMax: {
[Op.or]: {
[Op.gte]: floor,
[Op.is]: null
}
}
}
]
};
} else {
floorQuery = {
[Op.or]: [
{
[Op.and]: [
{
floorMin: {
[Op.is]: null
}
},
{
floorMax: {
[Op.is]: null
}
}
]
},
{
includeIncompleteAds: {
[Op.or]: {
[Op.eq]: true,
[Op.is]: null
}
}
}
]
};
}
}
//Logic for balcony, newBuilding and elevator
//If user dont check checkbox for ex. elevator it does not mean he only wants no elevator
//If real estate characteristic =true find all req, one that wants charachertistic or dont care - dont need query
//If real estate characteristic = false, find all req exept for ones that wants characteristic to be true
//If real estate characteristic = null, dont know if true or false, find req that dont care or want char and want incomplete ads
let balconyQuery = {};
if (realEstateTypeObject.hasBalconyProp && balcony !== true) {
if (balcony === false) {
balconyQuery = {
balcony: {
[Op.ne]: true
}
};
} else if (balcony === null) {
balconyQuery = {
[Op.or]: [
{
balcony: {
[Op.ne]: true
}
},
{
[Op.and]: [
{
balcony: {
[Op.eq]: true
}
},
{
includeIncompleteAds: {
[Op.or]: {
[Op.eq]: true,
[Op.is]: null
}
}
}
]
}
]
};
}
}
let newBuildingQuery = {};
if (realEstateTypeObject.hasNewBuildingProp && newBuilding !== true) {
if (newBuilding === false) {
newBuildingQuery = {
newBuilding: {
[Op.ne]: true
}
};
} else if (newBuilding === null) {
newBuildingQuery = {
[Op.or]: [
{
newBuilding: {
[Op.ne]: true
}
},
{
[Op.and]: [
{
newBuilding: {
[Op.eq]: true
}
},
{
includeIncompleteAds: {
[Op.or]: {
[Op.eq]: true,
[Op.is]: null
}
}
}
]
}
]
};
}
}
let elevatorQuery = {};
if (realEstateTypeObject.hasElevatorProp && elevator !== true) {
if (elevator === false) {
elevatorQuery = {
elevator: {
[Op.ne]: true
}
};
} else if (elevator === null) {
elevatorQuery = {
[Op.or]: [
{
elevator: {
[Op.ne]: true
}
},
{
[Op.and]: [
{
elevator: {
[Op.eq]: true
}
},
{
includeIncompleteAds: {
[Op.or]: {
[Op.eq]: true,
[Op.is]: null
}
}
}
]
}
]
};
}
}
//General query consists of each individual query
const query = { const query = {
adType, adType,
realEstateType, realEstateType,
subscribed: true, subscribed: true,
[Op.and]: geoSearchQueryPart [Op.and]: [
geoSearchQueryPart,
priceQuery,
areaQuery,
gardenSizeQuery,
numberOfRoomsQuery,
numberOfFloorsQuery,
floorQuery,
balconyQuery,
newBuildingQuery,
elevatorQuery
]
}; };
if (price) { //AccessRoadType is defined - should exists for each ad and estate type
query.priceMin = { if (accessRoadType != null) {
[Op.lte]: price query.accessRoadType = {
[Op.or]: {
[Op.like]: "ANY",
[Op.eq]: accessRoadType
}
}; };
query.priceMax = { } else {
[Op.gte]: price //Null values are returned for user request that wanted ANY acces road type
query.accessRoadType = {
[Op.eq]: "ANY"
};
}
//Tag to check if incomplete ads are accepted in query which is default
if (checkForIncompleteWanted) {
query.includeIncompleteAds = {
[Op.or]: {
[Op.eq]: true,
[Op.is]: null
}
}; };
} }
if (area) { return await db.SearchRequest.findAll({
query.sizeMin = { where: query
[Op.lte]: area });
};
query.sizeMax = {
[Op.gte]: area
};
}
return await db.SearchRequest.findAll({ where: query });
}; };
module.exports = { module.exports = {

View File

@@ -1,5 +1,8 @@
"use strict"; "use strict";
const db = require("../../models/index"); const db = require("../../models/index");
const sequelize = require("sequelize");
const Op = sequelize.Op;
const { CHECK_UP_DAYS } = require("../../config/appConfig");
const findRealEstatesForSearchRequest = async searchRequestId => { const findRealEstatesForSearchRequest = async searchRequestId => {
const query = { const query = {
@@ -24,6 +27,59 @@ const findRealEstatesForSearchRequest = async searchRequestId => {
return matchingRealEstates; return matchingRealEstates;
}; };
const findNotNotifiedMatches = async () => {
const query = {
notified: false
};
const searchRequestsModel = { model: db.SearchRequest, as: "searchRequests" };
const realEstateModel = { model: db.RealEstate, as: "realEstates" };
const include = [searchRequestsModel, realEstateModel];
const matchingRecords = await db.SearchRequestMatch.findAll({
where: query,
include
});
return matchingRecords;
};
const findAllRequestsForCheckUp = async () => {
//First we find IDs of search request that don't need to be emailed for check up - to EXCLUDE
//The ones that received notification for real estate CHECK_UP_DAYS days from now
const date = new Date();
const checkUpDate = date.getDate() - CHECK_UP_DAYS;
date.setDate(checkUpDate);
const dateQuery = {
createdAt: {
[Op.gte]: date
}
};
const excludedMatches = await db.SearchRequestMatch.findAll({
attributes: ["searchRequestId"],
where: dateQuery,
order: [["searchRequestId", "ASC"]]
});
const excludedRequestsAll = excludedMatches.map(match => {
return match.dataValues.searchRequestId;
});
//Removing duplicate search request id-s for optimization
const excludedRequests = [...new Set(excludedRequestsAll)];
const query = {
subscribed: true,
id: {
[Op.notIn]: excludedRequests
}
};
const allRequestsForCheckUp = await db.SearchRequest.findAll({
where: query
});
return allRequestsForCheckUp;
};
const addMatches = async matchingRecords => { const addMatches = async matchingRecords => {
return await db.SearchRequestMatch.bulkCreate(matchingRecords, { return await db.SearchRequestMatch.bulkCreate(matchingRecords, {
ignoreDuplicates: true ignoreDuplicates: true
@@ -32,5 +88,7 @@ const addMatches = async matchingRecords => {
module.exports = { module.exports = {
findRealEstatesForSearchRequest, findRealEstatesForSearchRequest,
addMatches addMatches,
findNotNotifiedMatches,
findAllRequestsForCheckUp
}; };

View File

@@ -1,10 +1,18 @@
"use strict"; "use strict";
const { MAX_REAL_ESTATES_IN_EMAIL, APP_URL } = require("../config/appConfig"); const {
const { AD_CATEGORY } = require("../common/enums"); MAX_REAL_ESTATES_IN_EMAIL,
APP_URL,
STAGING
} = require("../config/appConfig");
const { AD_CATEGORY, AD_TYPE, EMAIL_FREQUENCY } = require("../common/enums");
const generateEmailFooter = searchRequestId => { //Tag to recognize staging from development
return `<div>Ako želite prestati dobijati obavještenja za ovu pretragu, <a href="${APP_URL}/odjava/${searchRequestId}">odjavite ovdje</a></div> const stagingTag = STAGING ? "[STAGING] " : "";
const generateEmailFooter = (searchRequestId, emailFrequencyTitle) => {
return ` <div>Trenutno ste prijavljeni da obavještenja o novim nekretninama primate <strong>${emailFrequencyTitle.toLowerCase()} </strong>.</div>
<div>Ako želite prestati dobijati obavještenja za ovu pretragu, <a href="${APP_URL}/odjava/${searchRequestId}">odjavite ovdje</a></div>
<div>Ako želite pogledati ili promijeniti uslove za ovu pretragu, <a href="${APP_URL}/pregled/${searchRequestId}">pogledajte ovdje</a></div> <div>Ako želite pogledati ili promijeniti uslove za ovu pretragu, <a href="${APP_URL}/pregled/${searchRequestId}">pogledajte ovdje</a></div>
<br/> <br/>
<strong>Vaš,<br/>Kivi tim</strong>`; <strong>Vaš,<br/>Kivi tim</strong>`;
@@ -20,19 +28,41 @@ const generateRealEstateLinks = realEstates => {
return realEstateLinks; return realEstateLinks;
}; };
const generateNotificationEmail = (realEstates, searchRequestId) => { const generateNotificationEmail = (
realEstates,
searchRequestId,
noAllRealEstates,
dailyNotification = false
) => {
const truncateList = realEstates.length > MAX_REAL_ESTATES_IN_EMAIL; const truncateList = realEstates.length > MAX_REAL_ESTATES_IN_EMAIL;
const realEstatesToShow = truncateList const realEstatesToShow = truncateList
? realEstates.slice(0, MAX_REAL_ESTATES_IN_EMAIL) ? realEstates.slice(0, MAX_REAL_ESTATES_IN_EMAIL)
: realEstates; : realEstates;
const allRealEstatesLink = `${APP_URL}/nekretnine/${searchRequestId}`; const allRealEstatesLink = `${APP_URL}/nekretnine/${searchRequestId}`;
const realEstateLinks = generateRealEstateLinks(realEstatesToShow);
const moreRealEstates = `<div>Kompletan spisak nekretnina možete pogledati na <a href="${allRealEstatesLink}">listi nekretnina</a><div>`;
const emailFooter = generateEmailFooter(searchRequestId);
return `<h3>Zdravo</h3> const emailFrequencyTitle = dailyNotification
<h4>Pronašli smo nekretnine koje odgovaraju Vašoj pretrazi</h4> ? EMAIL_FREQUENCY.DAILY.title
: EMAIL_FREQUENCY.ASAP.title;
const realEstateLinks = generateRealEstateLinks(realEstatesToShow);
const moreRealEstates = `<div>Kompletan spisak nekretnina (${noAllRealEstates}) možete pogledati na <a href="${allRealEstatesLink}">listi nekretnina</a><div>`;
const emailFooter = generateEmailFooter(searchRequestId, emailFrequencyTitle);
const asapMessageBody =
realEstates.length > 1
? "Pronašli smo nekretnine koje odgovaraju Vašoj pretrazi"
: "Pronašli smo nekretninu koja odgovara Vašoj pretrazi";
const dailyMessageBody =
realEstates.length > 1
? "U posljednja 24h objavljene su sljedeće nekretnine koje odgovaraju uslovima Vaše pretrage"
: "U posljednja 24h objavljena je sljedeća nekretnina koja odgovara uslovima Vaše pretrage";
const messageBody = dailyNotification ? dailyMessageBody : asapMessageBody;
return `<h3>${stagingTag}Zdravo</h3>
<h4>${messageBody}</h4>
<div> <div>
${realEstateLinks} ${realEstateLinks}
<div/> <div/>
@@ -44,6 +74,28 @@ const generateNotificationEmail = (realEstates, searchRequestId) => {
const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => { const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
const realEstateType = AD_CATEGORY[searchRequest.realEstateType]; const realEstateType = AD_CATEGORY[searchRequest.realEstateType];
let adTypeTitle = "";
switch (searchRequest.adType) {
case AD_TYPE.AD_TYPE_SALE.stringId:
adTypeTitle = AD_TYPE.AD_TYPE_SALE.title;
break;
case AD_TYPE.AD_TYPE_RENT.stringId:
adTypeTitle = AD_TYPE.AD_TYPE_RENT.title;
break;
default:
adTypeTitle = "-";
break;
}
let emailFrequencyTitle;
switch (searchRequest.emailFrequency) {
case EMAIL_FREQUENCY.ASAP.stringId:
emailFrequencyTitle = EMAIL_FREQUENCY.ASAP.title;
break;
case EMAIL_FREQUENCY.DAILY.stringId:
emailFrequencyTitle = EMAIL_FREQUENCY.DAILY.title;
break;
}
const { const {
id, id,
gardenSizeMin, gardenSizeMin,
@@ -55,6 +107,7 @@ const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
} = searchRequest; } = searchRequest;
const realEstateLinks = generateRealEstateLinks(matchingRealEstates); const realEstateLinks = generateRealEstateLinks(matchingRealEstates);
const instantRealEstatesText = `<br/> const instantRealEstatesText = `<br/>
<div> <div>
U međuvremenu pogledajte neke od nedavno objavljenih nekretnina koje odgovaraju Vašim uslovima pretrage :<br/> U međuvremenu pogledajte neke od nedavno objavljenih nekretnina koje odgovaraju Vašim uslovima pretrage :<br/>
@@ -65,13 +118,14 @@ const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
? `<div><strong>Kvadratura okućnice: Od ${gardenSizeMin} do ${gardenSizeMax} m2</strong></div>` ? `<div><strong>Kvadratura okućnice: Od ${gardenSizeMin} do ${gardenSizeMax} m2</strong></div>`
: ``; : ``;
const emailFooter = generateEmailFooter(id); const emailFooter = generateEmailFooter(id, emailFrequencyTitle);
return `<h3>Zdravo</h3> return `<h3>${stagingTag}Zdravo</h3>
<div>Naručili ste da Vam javimo ako se nekretnina sa navedenim uslovima pojavi u oglasima:</div> <div>Naručili ste da Vam javimo ako se nekretnina sa navedenim uslovima pojavi u oglasima:</div>
<br/> <br/>
<div> <div>
<div><strong>Tip nekretnine: </strong>${realEstateType.title}</div> <div><strong>Tip nekretnine: </strong>${realEstateType.title}</div>
<div><strong>Vrsta oglasa: </strong>${adTypeTitle}</div>
<div><strong>Kvadratura nekretnine:</strong> Od ${sizeMin} do ${sizeMax} m2</div> <div><strong>Kvadratura nekretnine:</strong> Od ${sizeMin} do ${sizeMax} m2</div>
${gardenSize} ${gardenSize}
<div><strong>Cijena:</strong> ${priceMin} do ${priceMax} KM</div> <div><strong>Cijena:</strong> ${priceMin} do ${priceMax} KM</div>
@@ -83,7 +137,7 @@ const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
const generateEmailSubject = (numberOfRealEstates, singleRealEstateTitle) => { const generateEmailSubject = (numberOfRealEstates, singleRealEstateTitle) => {
if (numberOfRealEstates === 1) { if (numberOfRealEstates === 1) {
return `Kivi: ${singleRealEstateTitle}`; return `${stagingTag}Kivi: ${singleRealEstateTitle}`;
} }
const leastSignificantDigit = numberOfRealEstates % 10; const leastSignificantDigit = numberOfRealEstates % 10;
@@ -91,7 +145,7 @@ const generateEmailSubject = (numberOfRealEstates, singleRealEstateTitle) => {
const secondLeastSignificantDigit = numberWithoutLastDigit % 10; const secondLeastSignificantDigit = numberWithoutLastDigit % 10;
if (leastSignificantDigit === 1 && secondLeastSignificantDigit !== 1) { if (leastSignificantDigit === 1 && secondLeastSignificantDigit !== 1) {
return `Kivi : ${numberOfRealEstates} nova nekretnina`; return `${stagingTag}Kivi : ${numberOfRealEstates} nova nekretnina`;
} }
if ( if (
@@ -99,14 +153,58 @@ const generateEmailSubject = (numberOfRealEstates, singleRealEstateTitle) => {
leastSignificantDigit <= 4 && leastSignificantDigit <= 4 &&
secondLeastSignificantDigit !== 1 secondLeastSignificantDigit !== 1
) { ) {
return `Kivi: ${numberOfRealEstates} nove nekretnine`; return `${stagingTag}Kivi: ${numberOfRealEstates} nove nekretnine`;
} }
return `Kivi: ${numberOfRealEstates} novih nekretnina`; return `${stagingTag}Kivi: ${numberOfRealEstates} novih nekretnina`;
};
const generateCheckUpEmail = searchRequest => {
const realEstateType = AD_CATEGORY[searchRequest.realEstateType];
const {
id,
gardenSizeMin,
gardenSizeMax,
sizeMin,
sizeMax,
priceMin,
priceMax
} = searchRequest;
let emailFrequencyTitle;
switch (searchRequest.emailFrequency) {
case EMAIL_FREQUENCY.ASAP.stringId:
emailFrequencyTitle = EMAIL_FREQUENCY.ASAP.title;
break;
case EMAIL_FREQUENCY.DAILY.stringId:
emailFrequencyTitle = EMAIL_FREQUENCY.DAILY.title;
break;
}
const gardenSize = realEstateType.hasGardenSize
? `<div><strong>Kvadratura okućnice: Od ${gardenSizeMin} do ${gardenSizeMax} m2</strong></div>`
: ``;
const emailFooter = generateEmailFooter(id, emailFrequencyTitle);
return `<h3>${stagingTag}Zdravo</h3>
<div><strong>Kivi tim traži nekretnine za Vas i kada to ne vidite.</strong></div>
<br />
<div>Vaša trenutno aktivna pretraga je:</div>
<br/>
<div>
<div><strong>Tip nekretnine: </strong>${realEstateType.title}</div>
<div><strong>Kvadratura nekretnine:</strong> Od ${sizeMin} do ${sizeMax} m2</div>
${gardenSize}
<div><strong>Cijena:</strong> ${priceMin} do ${priceMax} KM</div>
</div>
<br/>
${emailFooter}`;
}; };
module.exports = { module.exports = {
generateNotificationEmail, generateNotificationEmail,
generateNewSearchRequestEmail, generateNewSearchRequestEmail,
generateEmailSubject generateEmailSubject,
generateCheckUpEmail
}; };

View File

@@ -0,0 +1,21 @@
const nodeFetch = require("node-fetch");
const {
USER_AGENT,
USE_SCRAPER_API,
SCRAPER_API_KEY
} = require("../config/appConfig");
const fetch = async (url, options = {}) => {
const newOptions = Object.assign({}, options);
if (!newOptions["headers"]) {
newOptions["headers"] = {};
}
newOptions["headers"]["User-Agent"] = USER_AGENT;
const urlAdaptedForScraping = USE_SCRAPER_API
? `http://api.scraperapi.com/?api_key=${SCRAPER_API_KEY}&url=${url}`
: url;
return nodeFetch(urlAdaptedForScraping, newOptions);
};
module.exports = fetch;

View File

@@ -0,0 +1,15 @@
"use strict";
const { EMAIL_FREQUENCY } = require("../common/enums");
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("SearchRequests", "emailFrequency", {
type: Sequelize.TEXT,
defaultValue: EMAIL_FREQUENCY.ASAP.stringId
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("SearchRequests", "emailFrequency");
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("SearchRequests", "deletedEmail", {
type: Sequelize.TEXT
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("SearchRequests", "deletedEmail");
}
};

View File

@@ -0,0 +1,163 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("RealEstates", "numberOfRooms", {
type: Sequelize.REAL
}),
queryInterface.addColumn("RealEstates", "numberOfFloors", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("RealEstates", "floor", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("RealEstates", "accessRoadType", {
type: Sequelize.TEXT
}),
queryInterface.addColumn("RealEstates", "heatingType", {
type: Sequelize.TEXT
}),
queryInterface.addColumn("RealEstates", "furnishingType", {
type: Sequelize.TEXT
}),
queryInterface.addColumn("RealEstates", "balcony", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "newBuilding", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "elevator", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "water", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "electricity", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "drainageSystem", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "registeredInZkBooks", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "recentlyAdapted", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "parking", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "garage", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "gas", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "antiTheftDoor", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "airCondition", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "phoneConnection", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "cableTV", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "internet", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "basementAttic", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "storeRoom", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "videoSurveillance", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "alarm", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "suitableForStudents", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "includingBills", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "animalsAllowed", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "pool", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "exchange", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "urbanPlanPermit", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "buildingPermit", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "utilityConnection", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "distanceToRiver", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("RealEstates", "numberOfViewsAgency", {
type: Sequelize.INTEGER,
defaultValue: 0
}),
queryInterface.addColumn("RealEstates", "numberOfViewsKivi", {
type: Sequelize.INTEGER,
defaultValue: 0
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("RealEstates", "numberOfRooms"),
queryInterface.removeColumn("RealEstates", "numberOfFloors"),
queryInterface.removeColumn("RealEstates", "floor"),
queryInterface.removeColumn("RealEstates", "accessRoadType"),
queryInterface.removeColumn("RealEstates", "heatingType"),
queryInterface.removeColumn("RealEstates", "furnishingType"),
queryInterface.removeColumn("RealEstates", "balcony"),
queryInterface.removeColumn("RealEstates", "newBuilding"),
queryInterface.removeColumn("RealEstates", "elevator"),
queryInterface.removeColumn("RealEstates", "water"),
queryInterface.removeColumn("RealEstates", "electricity"),
queryInterface.removeColumn("RealEstates", "drainageSystem"),
queryInterface.removeColumn("RealEstates", "registeredInZkBooks"),
queryInterface.removeColumn("RealEstates", "recentlyAdapted"),
queryInterface.removeColumn("RealEstates", "parking"),
queryInterface.removeColumn("RealEstates", "garage"),
queryInterface.removeColumn("RealEstates", "gas"),
queryInterface.removeColumn("RealEstates", "antiTheftDoor"),
queryInterface.removeColumn("RealEstates", "airCondition"),
queryInterface.removeColumn("RealEstates", "phoneConnection"),
queryInterface.removeColumn("RealEstates", "cableTV"),
queryInterface.removeColumn("RealEstates", "internet"),
queryInterface.removeColumn("RealEstates", "basementAttic"),
queryInterface.removeColumn("RealEstates", "storeRoom"),
queryInterface.removeColumn("RealEstates", "videoSurveillance"),
queryInterface.removeColumn("RealEstates", "alarm"),
queryInterface.removeColumn("RealEstates", "suitableForStudents"),
queryInterface.removeColumn("RealEstates", "includingBills"),
queryInterface.removeColumn("RealEstates", "animalsAllowed"),
queryInterface.removeColumn("RealEstates", "pool"),
queryInterface.removeColumn("RealEstates", "exchange"),
queryInterface.removeColumn("RealEstates", "urbanPlanPermit"),
queryInterface.removeColumn("RealEstates", "buildingPermit"),
queryInterface.removeColumn("RealEstates", "utilityConnection"),
queryInterface.removeColumn("RealEstates", "distanceToRiver"),
queryInterface.removeColumn("RealEstates", "numberOfViewsAgency"),
queryInterface.removeColumn("RealEstates", "numberOfViewsKivi")
]);
}
};

View File

@@ -0,0 +1,64 @@
"use strict";
const { ACCESS_ROAD_TYPE, HEATING_TYPE } = require("../common/enums");
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("SearchRequests", "includeIncompleteAds", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("SearchRequests", "balcony", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("SearchRequests", "newBuilding", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("SearchRequests", "elevator", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("SearchRequests", "numberOfRoomsMin", {
type: Sequelize.REAL
}),
queryInterface.addColumn("SearchRequests", "numberOfRoomsMax", {
type: Sequelize.REAL
}),
queryInterface.addColumn("SearchRequests", "numberOfFloorsMin", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("SearchRequests", "numberOfFloorsMax", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("SearchRequests", "floorMin", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("SearchRequests", "floorMax", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("SearchRequests", "accessRoadType", {
type: Sequelize.TEXT,
defaultValue: ACCESS_ROAD_TYPE.ANY.id
}),
queryInterface.addColumn("SearchRequests", "heatingType", {
type: Sequelize.TEXT,
defaultValue: HEATING_TYPE.ANY.id
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("SearchRequests", "includeIncompleteAds"),
queryInterface.removeColumn("SearchRequests", "balcony"),
queryInterface.removeColumn("SearchRequests", "newBuilding"),
queryInterface.removeColumn("SearchRequests", "elevator"),
queryInterface.removeColumn("SearchRequests", "numberOfRoomsMin"),
queryInterface.removeColumn("SearchRequests", "numberOfRoomsMax"),
queryInterface.removeColumn("SearchRequests", "numberOfFloorsMin"),
queryInterface.removeColumn("SearchRequests", "numberOfFloorsMax"),
queryInterface.removeColumn("SearchRequests", "floorMin"),
queryInterface.removeColumn("SearchRequests", "floorMax"),
queryInterface.removeColumn("SearchRequests", "accessRoadType"),
queryInterface.removeColumn("SearchRequests", "heatingType")
]);
}
};

View File

@@ -0,0 +1,42 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
const tableFields = {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
allowNull: false,
primaryKey: true
},
realEstateId: {
type: Sequelize.BIGINT,
allowNull: false,
unique: "uniquePriceRealEstate",
references: {
model: "RealEstates",
key: "id"
},
onUpdate: "CASCADE",
onDelete: "SET NULL"
},
price: {
type: Sequelize.REAL,
unique: "uniquePriceRealEstate"
},
createdAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
},
updatedAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
}
};
return queryInterface.createTable("PriceHistory", tableFields);
},
down: queryInterface => {
return queryInterface.dropTable("PriceHistory", {});
}
};

View File

@@ -0,0 +1,10 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) =>
queryInterface.addConstraint("PriceHistory", ["realEstateId", "price"], {
type: "unique",
name: "uniquePriceRealEstate"
}),
down: queryInterface =>
queryInterface.removeConstraint("PriceHistory", "uniquePriceRealEstate")
};

View File

@@ -0,0 +1,14 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("SearchRequests", "includeWithoutPrice", {
type: Sequelize.BOOLEAN,
defaultValue: true
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("SearchRequests", "includeWithoutPrice");
}
};

View File

@@ -0,0 +1,44 @@
"use strict";
module.exports = (sequalize, DataTypes) => {
const PriceHistory = sequalize.define(
"PriceHistory",
{
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
realEstateId: {
type: DataTypes.BIGINT,
allowNull: false,
unique: "uniquePriceRealEstate",
references: {
model: "RealEstates",
key: "id"
},
onUpdate: "CASCADE",
onDelete: "SET NULL"
},
price: {
type: DataTypes.REAL,
unique: "uniquePriceRealEstate"
}
},
{
freezeTableName: true
}
);
PriceHistory.associate = models => {
PriceHistory.hasMany(models.RealEstate, {
foreignKey: "id",
sourceKey: "realEstateId",
targetKey: "id",
as: "realEstates"
});
};
return PriceHistory;
};

View File

@@ -48,7 +48,44 @@ module.exports = (sequelize, DataTypes) => {
longDescription: DataTypes.TEXT, longDescription: DataTypes.TEXT,
adStatus: DataTypes.INTEGER, adStatus: DataTypes.INTEGER,
publishedDate: DataTypes.DATE, publishedDate: DataTypes.DATE,
renewedDate: DataTypes.DATE renewedDate: DataTypes.DATE,
numberOfRooms: DataTypes.INTEGER,
numberOfFloors: DataTypes.INTEGER,
floor: DataTypes.INTEGER,
accessRoadType: DataTypes.TEXT,
heatingType: DataTypes.TEXT,
furnishingType: DataTypes.TEXT,
balcony: DataTypes.BOOLEAN,
newBuilding: DataTypes.BOOLEAN,
elevator: DataTypes.BOOLEAN,
water: DataTypes.BOOLEAN,
electricity: DataTypes.BOOLEAN,
drainageSystem: DataTypes.BOOLEAN,
registeredInZkBooks: DataTypes.BOOLEAN,
recentlyAdapted: DataTypes.BOOLEAN,
parking: DataTypes.BOOLEAN,
garage: DataTypes.BOOLEAN,
gas: DataTypes.BOOLEAN,
antiTheftDoor: DataTypes.BOOLEAN,
airCondition: DataTypes.BOOLEAN,
phoneConnection: DataTypes.BOOLEAN,
cableTV: DataTypes.BOOLEAN,
internet: DataTypes.BOOLEAN,
basementAttic: DataTypes.BOOLEAN,
storeRoom: DataTypes.BOOLEAN,
videoSurveillance: DataTypes.BOOLEAN,
alarm: DataTypes.BOOLEAN,
suitableForStudents: DataTypes.BOOLEAN,
includingBills: DataTypes.BOOLEAN,
animalsAllowed: DataTypes.BOOLEAN,
pool: DataTypes.BOOLEAN,
exchange: DataTypes.BOOLEAN,
urbanPlanPermit: DataTypes.BOOLEAN,
buildingPermit: DataTypes.BOOLEAN,
utilityConnection: DataTypes.BOOLEAN,
distanceToRiver: DataTypes.INTEGER,
numberOfViewsAgency: DataTypes.INTEGER,
numberOfViewsKivi: DataTypes.INTEGER
}); });
return RealEstate; return RealEstate;

View File

@@ -1,6 +1,6 @@
"use strict"; "use strict";
const { AD_TYPE } = require("../common/enums"); const { AD_TYPE, EMAIL_FREQUENCY } = require("../common/enums");
module.exports = (sequelize, DataTypes) => { module.exports = (sequelize, DataTypes) => {
const SearchRequest = sequelize.define("SearchRequest", { const SearchRequest = sequelize.define("SearchRequest", {
@@ -61,7 +61,28 @@ module.exports = (sequelize, DataTypes) => {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: false, defaultValue: false,
allowNull: false allowNull: false
} },
emailFrequency: {
type: DataTypes.TEXT,
defaultValue: EMAIL_FREQUENCY.ASAP.stringId,
allowNull: false
},
deletedEmail: {
type: DataTypes.TEXT
},
includeIncompleteAds: DataTypes.BOOLEAN,
includeWithoutPrice: DataTypes.BOOLEAN,
balcony: DataTypes.BOOLEAN,
elevator: DataTypes.BOOLEAN,
newBuilding: DataTypes.BOOLEAN,
numberOfRoomsMin: DataTypes.REAL,
numberOfRoomsMax: DataTypes.REAL,
numberOfFloorsMin: DataTypes.INTEGER,
numberOfFloorsMax: DataTypes.INTEGER,
floorMin: DataTypes.INTEGER,
floorMax: DataTypes.INTEGER,
accessRoadType: DataTypes.TEXT,
heatingType: DataTypes.TEXT
}); });
return SearchRequest; return SearchRequest;

View File

@@ -44,6 +44,12 @@ module.exports = (sequelize, DataTypes) => {
); );
SearchRequestMatch.associate = models => { SearchRequestMatch.associate = models => {
SearchRequestMatch.hasMany(models.SearchRequest, {
foreignKey: "id",
sourceKey: "searchRequestId",
targetKey: "id",
as: "searchRequests"
});
SearchRequestMatch.hasMany(models.RealEstate, { SearchRequestMatch.hasMany(models.RealEstate, {
foreignKey: "id", foreignKey: "id",
as: "realEstates" as: "realEstates"

View File

@@ -0,0 +1,6 @@
"use strict";
const { checkUpNotify } = require("../services/notificationService");
//For testing pursposes
(async () => {
await checkUpNotify();
})();

View File

@@ -0,0 +1,8 @@
"use strict";
const {
notifyRequestsWithDailyOption
} = require("../services/notificationService");
(async () => {
await notifyRequestsWithDailyOption();
})();

View File

@@ -102,3 +102,59 @@ h3 {
border-radius: 4px !important; border-radius: 4px !important;
text-align: center; text-align: center;
} }
.collection a.collection-item {
color: #02adba;
}
.collection a.collection-item:not(.active):hover {
background-color: rgba(2, 173, 186, 0.2);
}
.tabs .tab a {
color: #02adba;
-webkit-transition: color 0.28s ease, background-color 0.28s ease;
transition: color 0.28s ease, background-color 0.28s ease;
}
.tabs .tab a:focus,
.tabs .tab a:focus.active {
background-color: rgba(2, 173, 186, 0.2);
}
.tabs .tab a:hover,
.tabs .tab a.active {
color: #02adba;
}
.tabs .indicator {
background-color: #02adba;
}
[type="checkbox"].filled-in:checked + span:not(.lever):after {
border: 2px solid #02adba;
background-color: #02adba;
}
[type="checkbox"].filled-in:not(:checked) + span:not(.lever):after {
background-color: transparent;
border: 2px solid #02adba;
}
.distinguished {
border: 2px solid #02adba;
border-radius: 4px;
padding: 5px 5px 3px 5px;
margin-left: -5px;
}
.checkbox-label {
color: black;
font-size: 14px;
}
.column-label {
position: relative;
margin-top: 2rem;
margin-bottom: 1rem;
}
.estates-link {
color: rgba(0, 0, 0, 0.87);
}

View File

@@ -1,14 +1,18 @@
.ui-segment { .segmented {
color: #02adba; color: #02adba;
border: 1px solid #02adba; border: 1px solid #02adba;
border-radius: 4px; border-radius: 4px;
display: inline-block; display: inline-block;
} }
.ui-segment span.option.active { .segmented label {
color: #02adba;
}
.segmented input:checked + .label {
background-color: #02adba; background-color: #02adba;
color: white; color: white;
} }
.ui-segment span.option { [type="radio"]:not(:checked) + span,
[type="radio"]:checked + span {
padding-left: 30px; padding-left: 30px;
padding-right: 30px; padding-right: 30px;
height: 35px; height: 35px;
@@ -21,9 +25,20 @@
border-right: 1px solid #02adba; border-right: 1px solid #02adba;
} }
.ui-segment span.option:last-child { .segmented.small [type="radio"]:not(:checked) + span,
.segmented.small [type="radio"]:checked + span {
padding-left: 7px;
padding-right: 7px;
}
.segmented :last-child .label {
border-right: none; border-right: none;
} }
.segment-select { .segmented input {
display: none;
}
span.label:before,
span.label:after {
display: none; display: none;
} }

View File

@@ -2,7 +2,7 @@
const express = require("express"); const express = require("express");
const welcome = require("../controllers/welcome").getWelcome; const { getWelcome, postWelcome } = require("../controllers/welcome");
const { const {
getRealEstateTypes, getRealEstateTypes,
postRealEstateTypes postRealEstateTypes
@@ -20,7 +20,8 @@ const { getFilters, postFilters } = require("../controllers/realEstateFilters");
const router = express.Router(); const router = express.Router();
router.get("/", welcome); router.get("/", getWelcome);
router.post("/", postWelcome);
router.get("/vrstanekretnine/:searchRequestId", getRealEstateTypes); router.get("/vrstanekretnine/:searchRequestId", getRealEstateTypes);
router.get("/vrstanekretnine", getRealEstateTypes); router.get("/vrstanekretnine", getRealEstateTypes);

View File

@@ -0,0 +1,34 @@
"use strict";
const { RealEstate } = require("../models");
module.exports = {
async up(queryInterface, Sequelize) {
//Reading initial data from RealEstate table in db
const realEstateInitialData = await RealEstate.findAll();
//Extruding data for table PriceHistory
const priceHistoryInitialData = realEstateInitialData.map(realEstate => {
//Null values canot be recognized by ignore duplicates in sequalize
//Value price = 0 indicates 'cijena na upit'
const priceTmp =
realEstate.dataValues.price === null ? 0 : realEstate.dataValues.price;
return {
realEstateId: realEstate.dataValues.id,
price: priceTmp,
createdAt: realEstate.dataValues.createdAt,
updatedAt: realEstate.dataValues.updatedAt
};
});
return queryInterface.bulkInsert(
"PriceHistory",
priceHistoryInitialData,
{}
);
},
async down(queryInterface, Sequelize) {
return queryInterface.bulkDelete("PriceHistory", null, {});
}
};

View File

@@ -1,4 +1,8 @@
"use strict"; "use strict";
const { STAGING } = require("../config/appConfig");
const stagingTag = STAGING ? "[STAGING] " : "";
const { const {
matchRealEstates, matchRealEstates,
matchSearchRequest matchSearchRequest
@@ -6,8 +10,14 @@ const {
const { const {
generateNotificationEmail, generateNotificationEmail,
generateNewSearchRequestEmail, generateNewSearchRequestEmail,
generateEmailSubject generateEmailSubject,
generateCheckUpEmail
} = require("../helpers/emailContentGenerator"); } = require("../helpers/emailContentGenerator");
const {
findNotNotifiedMatches,
findAllRequestsForCheckUp,
findRealEstatesForSearchRequest
} = require("../helpers/db/searchRequestMatch");
const { sendEmail } = require("../services/emailService"); const { sendEmail } = require("../services/emailService");
const notifyForNewRealEstates = async newRealEstates => { const notifyForNewRealEstates = async newRealEstates => {
@@ -20,27 +30,41 @@ const notifyForNewSearchRequest = async searchRequest => {
const searchRequestId = searchRequest.id; const searchRequestId = searchRequest.id;
const matchingRealEstates = matches[searchRequestId].realEstates; const matchingRealEstates = matches[searchRequestId].realEstates;
const emailContent = generateNewSearchRequestEmail( const emailContent = generateNewSearchRequestEmail(
searchRequest, searchRequest,
matchingRealEstates matchingRealEstates
); );
const { email } = searchRequest; const { email } = searchRequest;
await sendEmail(email, "Kivi - novi zahtjev za pretragu", emailContent);
await sendEmail(
email,
`${stagingTag} Kivi - novi zahtjev za pretragu`,
emailContent
);
}; };
const notifyMatches = async matches => { const notifyMatches = async (matches, dailyNotification = false) => {
const searchRequestsToNotify = Object.keys(matches); const searchRequestsToNotify = Object.keys(matches);
const asyncSendEmailActions = []; const asyncSendEmailActions = [];
for (const id of searchRequestsToNotify) { for (const id of searchRequestsToNotify) {
const { searchRequest } = matches[id]; const { searchRequest, notifyNow } = matches[id];
const { email } = searchRequest; const { email, subscribed } = searchRequest;
if (notifyNow && subscribed) {
const allMatchingRealEstates = matches[id].realEstates || []; const allMatchingRealEstates = matches[id].realEstates || [];
//Variable allMatchingRealEstates are real estates that are "new" on the market
//the ones that we notify user in this moment, not all that already exists in db
//New variable allRealEstates are all real estates that exists in db for search req
const allRealEstates = await findRealEstatesForSearchRequest(id);
const noAllRealEstates = allRealEstates.length;
if (allMatchingRealEstates.length > 0) { if (allMatchingRealEstates.length > 0) {
const emailContent = generateNotificationEmail( const emailContent = generateNotificationEmail(
allMatchingRealEstates, allMatchingRealEstates,
id id,
noAllRealEstates,
dailyNotification
); );
const emailSubject = generateEmailSubject( const emailSubject = generateEmailSubject(
allMatchingRealEstates.length, allMatchingRealEstates.length,
@@ -49,14 +73,83 @@ const notifyMatches = async matches => {
const sendEmailPromise = sendEmail(email, emailSubject, emailContent); const sendEmailPromise = sendEmail(email, emailSubject, emailContent);
asyncSendEmailActions.push(sendEmailPromise); asyncSendEmailActions.push(sendEmailPromise);
sendEmailPromise.catch(err => console.log("[Email Sending Failed]", err)); sendEmailPromise.catch(err =>
console.log("[Email Sending Failed]", err)
);
}
} }
} }
await Promise.all(asyncSendEmailActions); await Promise.all(asyncSendEmailActions);
}; };
const notifyRequestsWithDailyOption = async () => {
const notNotifiedSearchRequestMatches = await findNotNotifiedMatches();
const matches = {};
for (const searchRequestMatch of notNotifiedSearchRequestMatches) {
const { searchRequests, realEstates } = searchRequestMatch;
if (!Array.isArray(searchRequests) || searchRequests.length !== 1) {
// Something is wrong with this match
// (search request not found for specified search request id)
// OR
// there are multiple search requests with the same ID (this should never be the case !
// TODO: Maybe if association is defined better, this will be automatically only one object instead of array
continue;
}
if (!Array.isArray(realEstates) || realEstates.length !== 1) {
// Something is wrong with this match
// (real estate not found for specified real estate id)
// OR
// there are multiple real estates with the same ID (this should never be the case !
// TODO: Maybe if association is defined better, this will be automatically only one object instead of array
continue;
}
const searchRequest = searchRequests[0];
const realEstate = realEstates[0];
const searchRequestId = searchRequest.id;
if (!matches[searchRequestId]) {
matches[searchRequestId] = {
searchRequest,
realEstates: [],
notifyNow: true
};
}
matches[searchRequestId].realEstates.push(realEstate);
searchRequestMatch.notified = true;
searchRequestMatch.save();
}
await notifyMatches(matches, true);
};
const checkUpNotify = async () => {
/* const searchRequestsForCheckUp = await findAllRequestsForCheckUp();
const asyncSendEmailActions = [];
for (const searchRequest of searchRequestsForCheckUp) {
const { email } = searchRequest.dataValues;
const emailSubject = `${stagingTag}Kivi: Mi tražimo nekretnine za vas!`;
const emailContent = generateCheckUpEmail(searchRequest.dataValues);
const sendEmailPromise = sendEmail(email, emailSubject, emailContent);
asyncSendEmailActions.push(sendEmailPromise);
sendEmailPromise.catch(err => console.log("[Email Sending Failed]", err));
}
await Promise.all(asyncSendEmailActions); */
};
module.exports = { module.exports = {
notifyForNewRealEstates, notifyForNewRealEstates,
notifyForNewSearchRequest notifyForNewSearchRequest,
notifyRequestsWithDailyOption,
checkUpNotify
}; };

View File

@@ -6,6 +6,7 @@ const {
const { findRealEstatesForSearchRequest } = require("../helpers/db/realEstate"); const { findRealEstatesForSearchRequest } = require("../helpers/db/realEstate");
const { addMatches } = require("../helpers/db/searchRequestMatch"); const { addMatches } = require("../helpers/db/searchRequestMatch");
const { MAX_REAL_ESTATES_IN_FIRST_EMAIL } = require("../config/appConfig"); const { MAX_REAL_ESTATES_IN_FIRST_EMAIL } = require("../config/appConfig");
const { EMAIL_FREQUENCY } = require("../common/enums");
const matchRealEstates = async realEstates => { const matchRealEstates = async realEstates => {
if (Array.isArray(realEstates)) { if (Array.isArray(realEstates)) {
@@ -18,18 +19,19 @@ const matchRealEstates = async realEstates => {
searchRequestsPromise.then(searchRequests => { searchRequestsPromise.then(searchRequests => {
for (const searchRequest of searchRequests) { for (const searchRequest of searchRequests) {
const { id } = searchRequest; const { id, emailFrequency } = searchRequest;
if (!matches[id]) { if (!matches[id]) {
matches[id] = { matches[id] = {
searchRequest, searchRequest,
realEstates: [] realEstates: [],
notifyNow: emailFrequency === EMAIL_FREQUENCY.ASAP.stringId
}; };
} }
matches[id].realEstates.push(realEstate); matches[id].realEstates.push(realEstate);
matchingRecords.push({ matchingRecords.push({
searchRequestId: searchRequest.id, searchRequestId: searchRequest.id,
realEstateId: realEstate.id, realEstateId: realEstate.id,
notified: false notified: emailFrequency === EMAIL_FREQUENCY.ASAP.stringId
}); });
} }
}); });
@@ -62,7 +64,7 @@ const matchSearchRequest = async searchRequest => {
matchingRecords.push({ matchingRecords.push({
searchRequestId, searchRequestId,
realEstateId: realEstate.id, realEstateId: realEstate.id,
notified: false notified: true
}); });
} }

View File

@@ -0,0 +1,68 @@
<br>
<% for (const filter of advancedBooleanFilterObjects){ %>
<p>
<label class="checkbox-label">
<input type="checkbox" class="filled-in" name="<%= filter.dbField %>"
<% if (advancedBooleanFilterValues[filter.dbField]) { %>
checked
<% } %>>
<span><%= filter.title %></span>
</label>
</p>
<% } %>
<br>
<% for (const filter of advancedRangeFilterObjects){ %>
<div class="row">
<p class="column-label col s5 m3 l2">
<%= filter.title %>
</p>
<div class="input-field col s3 m4 l5">
<input
id="<%= filter.dbFieldMin %>"
name="<%= filter.dbFieldMin %>"
type="number"
value="<%= advancedRangeFilterValues[filter.dbFieldMin] !== undefined ? advancedRangeFilterValues[filter.dbFieldMin] : ""%>"
>
<label for="<%= filter.dbFieldMin %>">Od</label>
</div>
<div class="input-field col s3 m4 l5">
<input
id="<%= filter.dbFieldMax %>"
name="<%= filter.dbFieldMax %>"
type="number"
value="<%= advancedRangeFilterValues[filter.dbFieldMax] !== undefined ? advancedRangeFilterValues[filter.dbFieldMax] : ""%>"
>
<label for="<%= filter.dbFieldMax %>">Do</label>
</div>
</div>
<% } %>
<br>
<% for (const filter of advancedSegmentSelectFilterObjects){ %>
<div>
<label class="checkbox-label"><%= filter.title %>: </label><br><br>
<span class="segmented small">
<% for (const segmentObject of filter.values) { %>
<label>
<input type="radio" name="<%= filter.dbField %>" value="<%= segmentObject.id %>"
<% if (advancedSegmentSelectFilterValues[filter.dbField] === segmentObject.id) { %>
checked
<% } %>>
<span class="label"><%= segmentObject.title %></span>
</label>
<% } %>
</span>
</div>
<% } %>
<br>
<p class="distinguished">
<label class="checkbox-label">
<input type="checkbox" class="filled-in" name="includeIncompleteAds"
checked
>
<span>Uključi i oglase bez potpunih informacija</span>
</label>
</p>

View File

@@ -1,13 +1,17 @@
<div class="row center-align"> <div class="row center-align">
<h3> <h3>
Područje na mapi će biti uključeno u pretragu. Namjestite mapu na ulice Područje na mapi će biti uključeno u pretragu. Namjestite mapu na ulice koje
koje želite da budu vidljive. želite da budu vidljive.
</h3> </h3>
</div> </div>
<div class="row center-align"> <div class="row center-align">
<div class="col s12 m12 l12 xl12"> <div class="col s12 m12 l12 xl12">
<input id="autocompleteInput" placeholder="Unesite grad, naselje ili ulicu..." type="text" /> <input
id="autocompleteInput"
placeholder="Unesite grad, naselje ili ulicu..."
type="text"
/>
</div> </div>
</div> </div>
@@ -17,12 +21,17 @@
</div> </div>
</div> </div>
<br> <br />
<form method="POST" id="form-map-output"> <form method="POST" id="form-map-output">
<div class="row center-align"> <div class="row center-align">
<div class="col s6 push-s3"> <div class="col s6 push-s3">
<a id="submit" href="#" class="welcome-center-button waves-effect waves-light btn">Dalje</a> <a
id="submit"
href="#"
class="welcome-center-button waves-effect waves-light btn"
>Dalje</a
>
</div> </div>
</div> </div>
<input type="hidden" name="north" id="north" /> <input type="hidden" name="north" id="north" />
@@ -41,9 +50,9 @@
function locateMe() { function locateMe() {
if (navigator.geolocation) { if (navigator.geolocation) {
function onLocationSuccess(position) {
const onLocationSuccess = (position) => { const coordinates =
const coordinates = position && position.coords ? position.coords : null; position && position.coords ? position.coords : null;
if (coordinates) { if (coordinates) {
const longitude = coordinates.longitude || null; const longitude = coordinates.longitude || null;
const latitude = coordinates.latitude || null; const latitude = coordinates.latitude || null;
@@ -53,7 +62,7 @@
map.setZoom(16); map.setZoom(16);
} }
} }
}; }
navigator.geolocation.getCurrentPosition(onLocationSuccess); navigator.geolocation.getCurrentPosition(onLocationSuccess);
} }
@@ -61,20 +70,20 @@
function initMap() { function initMap() {
const BOSNIA_BOUNDS = { const BOSNIA_BOUNDS = {
north: 45.70, north: 45.7,
south: 41.69, south: 41.69,
west: 15.55, west: 15.55,
east: 20.77, east: 20.77
}; };
const SARAJEVO_COORDINATES = { const SARAJEVO_COORDINATES = {
lat: 43.85, lat: 43.85,
lng: 18.41, lng: 18.41
}; };
const mapElement = document.getElementById('map'); const mapElement = document.getElementById("map");
const restrictMapPanningToBosniaOnly = { const restrictMapPanningToBosniaOnly = {
latLngBounds: BOSNIA_BOUNDS, latLngBounds: BOSNIA_BOUNDS,
strictBounds: true, strictBounds: true
}; };
const initialMapParams = { const initialMapParams = {
center: SARAJEVO_COORDINATES, center: SARAJEVO_COORDINATES,
@@ -87,38 +96,50 @@
}; };
map = new google.maps.Map(mapElement, initialMapParams); map = new google.maps.Map(mapElement, initialMapParams);
const inputElement = document.getElementById('autocompleteInput'); const inputElement = document.getElementById("autocompleteInput");
const restrictAutocompleteResultsToBosniaOnly = {'country': 'ba'}; const restrictAutocompleteResultsToBosniaOnly = { country: "ba" };
const initialAutocompleteParams = { const initialAutocompleteParams = {
types: ['geocode'], types: ["geocode"],
componentRestrictions: restrictAutocompleteResultsToBosniaOnly, componentRestrictions: restrictAutocompleteResultsToBosniaOnly,
fields: ['geometry', 'types', 'address_components'] fields: ["geometry", "types", "address_components"]
}; };
autocomplete = new google.maps.places.Autocomplete(inputElement, initialAutocompleteParams); autocomplete = new google.maps.places.Autocomplete(
autocomplete.bindTo('bounds', map); inputElement,
autocomplete.addListener('place_changed', onPlaceChanged); initialAutocompleteParams
);
autocomplete.bindTo("bounds", map);
autocomplete.addListener("place_changed", onPlaceChanged);
pacSelectFirst(inputElement); pacSelectFirst(inputElement);
addLocateMeButton(map); addLocateMeButton(map);
//After map initialization we check if area is already selected
//If yes we bound map to show already selected area
const boundsSelected = <%- boundsSelected %>;
const selectedLatLngBounds = <%- JSON.stringify(selectedLatLngBounds) %>;
if (boundsSelected) {
boundMapToSelected(map, selectedLatLngBounds);
}
} }
function addLocateMeButton(map) { function addLocateMeButton(map) {
var parent = document.createElement('div'); var parent = document.createElement("div");
parent.className = "locate-me-container"; parent.className = "locate-me-container";
var a = document.createElement('a'); var a = document.createElement("a");
a.id = "locateMe"; a.id = "locateMe";
a.className = "btn-floating"; a.className = "btn-floating";
var i = document.createElement('i'); var i = document.createElement("i");
i.innerText = "gps_fixed"; i.innerText = "gps_fixed";
i.className = "material-icons right"; i.className = "material-icons right";
a.appendChild(i) a.appendChild(i);
a.addEventListener("click", locateMe); a.addEventListener("click", locateMe);
parent.appendChild(a) parent.appendChild(a);
map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(parent) map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(parent);
} }
function onPlaceChanged() { function onPlaceChanged() {
@@ -134,36 +155,52 @@
// store the original event binding function // store the original event binding function
const _addEventListener = input.addEventListener const _addEventListener = input.addEventListener
? input.addEventListener ? input.addEventListener
: input.attachEvent : input.attachEvent;
function addEventListenerWrapper(type, listener) { function addEventListenerWrapper(type, listener) {
// Simulate a 'down arrow' keypress on hitting 'return' when no pac suggestion is selected, // Simulate a 'down arrow' keypress on hitting 'return' when no pac suggestion is selected,
// and then trigger the original listener. // and then trigger the original listener.
if (type == 'keydown') { if (type == "keydown") {
const originalListener = listener const originalListener = listener;
listener = function(event) { listener = function(event) {
const suggestionSelected = $('.pac-item-selected').length > 0 const suggestionSelected = $(".pac-item-selected").length > 0;
if (event.key == 'Enter' && !suggestionSelected) { if (event.key == "Enter" && !suggestionSelected) {
const simulatedDownArrow = $.Event('keydown', { const simulatedDownArrow = $.Event("keydown", {
keyCode: 40, keyCode: 40,
which: 40 which: 40
}) });
originalListener.apply(input, [simulatedDownArrow]) originalListener.apply(input, [simulatedDownArrow]);
} }
originalListener.apply(input, [event]) originalListener.apply(input, [event]);
} };
} }
_addEventListener.apply(input, [type, listener]) _addEventListener.apply(input, [type, listener]);
} }
input.addEventListener = addEventListenerWrapper input.addEventListener = addEventListenerWrapper;
input.attachEvent = addEventListenerWrapper input.attachEvent = addEventListenerWrapper;
}
function boundMapToSelected(map, selectedLatLngBounds) {
const swBound = new google.maps.LatLng(
selectedLatLngBounds.swLat,
selectedLatLngBounds.swLng
);
const neBound = new google.maps.LatLng(
selectedLatLngBounds.neLat,
selectedLatLngBounds.neLng
);
let bounds = new google.maps.LatLngBounds();
bounds.extend(swBound);
bounds.extend(neBound);
map.fitBounds(bounds);
} }
$(document).ready(() => { $(document).ready(function() {
$("#submit").click(() => { $("#submit").click(function() {
const mapBounds = map.getBounds(); const mapBounds = map.getBounds();
$("#north").val(mapBounds.getNorthEast().lat()); $("#north").val(mapBounds.getNorthEast().lat());
@@ -171,11 +208,16 @@
$("#east").val(mapBounds.getNorthEast().lng()); $("#east").val(mapBounds.getNorthEast().lng());
$("#west").val(mapBounds.getSouthWest().lng()); $("#west").val(mapBounds.getSouthWest().lng());
$("#locationInput").val(document.getElementById('autocompleteInput').value); $("#locationInput").val(
document.getElementById("autocompleteInput").value
);
$("#form-map-output").submit(); $("#form-map-output").submit();
}); });
}); });
</script> </script>
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyAna8ohfV2HBMcxGk_29vqxU5Z_bDickqg&language=bs&libraries=places&callback=initMap" async <script
defer></script> src="https://maps.googleapis.com/maps/api/js?key=<%= process.env.API_MAP_KEY %>&language=bs&libraries=places&callback=initMap"
async
defer
></script>

View File

@@ -1,53 +0,0 @@
<form method="POST" id="form-range">
<div class="row center-align no-ui-slider centered-element-small" id="slider"></div>
<div class="col s6 push-s3 centered-element-small">
<a id="btnsubmit" href="#" class="next-center-button waves-effect waves-light btn">
Dalje
</a>
</div>
<input type="hidden" name="from" id="from" />
<input type="hidden" name="to" id="to" />
</form>
<script>
$(document).ready(() => {
var slider = document.getElementById('slider');
const unitFormat = wNumb({
decimals: 3,
thousand: '.',
suffix: '<%= unit %>'
})
noUiSlider.create(slider, {
start: [<%= rangeFrom.value %>, <%= rangeTo.value %>],
connect: true,
tooltips: true,
step: <%= rangeFrom.step %>,
range: {
'min': <%= rangeFrom.min %>,
'max': <%= rangeTo.max %>
},
format: unitFormat
});
$("#btnsubmit").click(() => {
const sliderValues = slider.noUiSlider.get();
$("#from").val(unitFormat.from(sliderValues[0]));
$("#to").val(unitFormat.from(sliderValues[1]));
$("#form-range").submit();
// });
});
});
</script>

View File

@@ -1,7 +1,8 @@
<br><br>
<form method="POST" id="form-queryreview"> <form method="POST" id="form-queryreview">
<div class="row center-align"> <div class="row center-align">
<ul class="collection with-header"> <ul class="collection with-header">
<% for(const stepData of queryReviewData) { %> <% for(const stepData of queryReviewTableData) { %>
<li class="collection-item" > <li class="collection-item" >
<div id="<%= stepData.id %>" ><%= stepData.title || '-' %> <div id="<%= stepData.id %>" ><%= stepData.title || '-' %>
<a href="<%= stepData.url %>" class="kivi-color secondary-content"> <a href="<%= stepData.url %>" class="kivi-color secondary-content">
@@ -12,6 +13,26 @@
<% } %> <% } %>
</ul> </ul>
</div> </div>
<div class="row center-align">
<h6>Slanje obavještenja</h6>
<span class="segmented">
<label>
<input type="radio" name="emailFrequency" value="<%= EMAIL_FREQUENCY.ASAP.id %>"
<% if (selectedEmailFrequency === EMAIL_FREQUENCY.ASAP.id) { %>
checked
<% } %>>
<span class="label"><%= EMAIL_FREQUENCY.ASAP.title %></span>
</label>
<label>
<input type="radio" name="emailFrequency" value="<%= EMAIL_FREQUENCY.DAILY.id %>"
<% if (selectedEmailFrequency === EMAIL_FREQUENCY.DAILY.id) { %>
checked
<% } %>>
<span class="label"><%= EMAIL_FREQUENCY.DAILY.title %></span>
</label>
</span>
</div>
<div class="row center-align"> <div class="row center-align">
<div class="col"> <div class="col">
<input id="email" name="email" type="email" placeholder="vas.email@mail.com" <% if (email) { %>value="<%= email %>" <% } %> required size="250" /> <input id="email" name="email" type="email" placeholder="vas.email@mail.com" <% if (email) { %>value="<%= email %>" <% } %> required size="250" />
@@ -46,8 +67,9 @@
</form> </form>
<script> <script>
$(document).ready( () => {
$("#submit").click( () => { $(document).ready(function() {
$("#submit").click(function() {
const simpleEmailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; const simpleEmailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const email = $("#email").val(); const email = $("#email").val();
const confirmEmail = $("#confirmEmail").val(); const confirmEmail = $("#confirmEmail").val();
@@ -61,7 +83,7 @@
$("#submit").attr("disabled", true); $("#submit").attr("disabled", true);
$("#form-queryreview").submit(); $("#form-queryreview").submit();
}else{ }else{
$("#error-label-email").text("Greška ! Unešeni emailovi nisu isti"); $("#error-label-email").text("Greška ! Unešeni email nije validan");
} }
}); });
}); });

View File

@@ -1,63 +1,20 @@
<br>
<form id="filtersForm" method="POST"> <form id="filtersForm" method="POST">
<br>
<div class="row center-align">
<h5>Cijena</h5>
<br><br>
<div class="center-align no-ui-slider" id="priceFilter"></div>
</div>
<br>
<div class="row"> <div class="row">
<div class="col s5 m3 l3 push-m1 push-l2"> <div class="col s12">
<input class="sliderInputBox" type="number" id="priceMin" name="priceMin"> <ul class="tabs">
<li class="tab col s6"><a href="#standardFilters">Filteri</a></li>
<li class="tab col s6"><a href="#advancedFilters">Napredni filteri</a></li>
</ul>
</div> </div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4"> <div id="standardFilters" class="col s12">
<input class="sliderInputBox" type="number" id="priceMax" name="priceMax"> <%- include("./standardFilters.ejs") %>
</div>
<div id="advancedFilters" class="col s12">
<%- include("./advancedFilters.ejs") %>
</div> </div>
</div> </div>
<br>
<div class="row center-align">
<h5>Površina</h5>
<br><br>
<div class="center-align no-ui-slider" id="sizeFilter"></div>
</div>
<br>
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input class="sliderInputBox" type="number" id="sizeMin" name="sizeMin">
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input class="sliderInputBox" type="number" id="sizeMax" name="sizeMax">
</div>
</div>
<br>
<% if(hasGardenSize) { %>
<div class="row center-align">
<h5>Površina okućnice</h5>
<br><br>
<div class="center-align no-ui-slider" id="gardenSizeFilter"></div>
</div>
<br>
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input class="sliderInputBox" type="number" id="gardenSizeMin" name="gardenSizeMin">
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input class="sliderInputBox" type="number" id="gardenSizeMax" name="gardenSizeMax">
</div>
</div>
<% } %>
<div class="row"> <div class="row">
<div class="col s6 push-s3"> <div class="col s6 push-s3">
<a id="submit" href="#" class="welcome-center-button waves-effect waves-light btn">Dalje</a> <a id="submit" href="#" class="welcome-center-button waves-effect waves-light btn">Dalje</a>
@@ -66,125 +23,7 @@
</form> </form>
<script> <script>
$(document).ready(() => { $(document).ready(function(){
const priceSliderOptions = {...<%- priceSliderOptions %>}; $('.tabs').tabs();
const sizeSliderOptions = {...<%- sizeSliderOptions %>};
const priceStep = priceSliderOptions.step;
const sizeStep = sizeSliderOptions.step;
delete priceSliderOptions.step;
delete sizeSliderOptions.step;
const updatePriceInputs = (values, handle, unencoded) => {
$("#priceMin").val(Math.round(unencoded[0]/priceStep)*priceStep);
$("#priceMax").val(Math.round(unencoded[1]/priceStep)*priceStep);
}
const updateSizeInputs = (values, handle, unencoded) => {
$("#sizeMin").val(Math.round(unencoded[0]/sizeStep)*sizeStep);
$("#sizeMax").val(Math.round(unencoded[1]/sizeStep)*sizeStep);
}
const priceSlider = document.getElementById("priceFilter");
const sizeSlider = document.getElementById("sizeFilter");
const priceSliderObject = noUiSlider.create(priceSlider, priceSliderOptions);
const sizeSliderObject = noUiSlider.create(sizeSlider, sizeSliderOptions);
priceSliderObject.on('slide', updatePriceInputs);
sizeSliderObject.on('slide', updateSizeInputs);
const priceMinChangeHandler = (element) => {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = priceSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
priceSliderObject.set([fixedNewValue, null]);
$("#priceMin").val(Math.round(priceSliderObject.get()[0]));
}
}
const priceMaxChangeHandler = (element) => {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
priceSliderObject.set([null, newValue]);
$("#priceMax").val(Math.round(priceSliderObject.get()[1]));
}
}
$("#priceMin").val(priceSliderOptions.start[0]);
$("#priceMax").val(priceSliderOptions.start[1]);
$("#priceMin").change(priceMinChangeHandler);
$("#priceMax").change(priceMaxChangeHandler);
const sizeMinChangeHandler = (element) => {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = sizeSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
sizeSliderObject.set([fixedNewValue, null]);
$("#sizeMin").val(Math.round(sizeSliderObject.get()[0]));
}
}
const sizeMaxChangeHandler = (element) => {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
sizeSliderObject.set([null, newValue]);
$("#sizeMax").val(Math.round(sizeSliderObject.get()[1]));
}
}
$("#sizeMin").val(sizeSliderOptions.start[0]);
$("#sizeMax").val(sizeSliderOptions.start[1]);
$("#sizeMin").change(sizeMinChangeHandler);
$("#sizeMax").change(sizeMaxChangeHandler);
<% if(hasGardenSize) { %>
const gardenSizeSliderOptions = {...<%- gardenSizeSliderOptions %>};
const gardenSizeStep = gardenSizeSliderOptions.step;
delete gardenSizeSliderOptions.step;
const updateGardenSizeInputs = (values, handle, unencoded) => {
$("#gardenSizeMin").val(Math.round(unencoded[0]/gardenSizeStep)*gardenSizeStep);
$("#gardenSizeMax").val(Math.round(unencoded[1]/gardenSizeStep)*gardenSizeStep);
}
const gardenSizeSlider = document.getElementById("gardenSizeFilter");
const gardenSizeSliderObject = noUiSlider.create(gardenSizeSlider, gardenSizeSliderOptions);
gardenSizeSliderObject.on('slide', updateGardenSizeInputs);
const gardenSizeMinChangeHandler = (element) => {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = gardenSizeSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
gardenSizeSliderObject.set([fixedNewValue, null]);
$("#gardenSizeMin").val(Math.round(gardenSizeSliderObject.get()[0]));
}
}
const gardenSizeMaxChangeHandler = (element) => {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
gardenSizeSliderObject.set([null, newValue]);
$("#gardenSizeMin").val(Math.round(gardenSizeSliderObject.get()[0]));
}
}
$("#gardenSizeMin").val(gardenSizeSliderOptions.start[0]);
$("#gardenSizeMax").val(gardenSizeSliderOptions.start[1]);
$("#gardenSizeMin").change("step", gardenSizeMinChangeHandler);
$("#gardenSizeMax").change("step", gardenSizeMaxChangeHandler);
<% } %>
$("#submit").click(() => {
const priceFilterValues = priceSlider.noUiSlider.get();
$("#priceFilterMin").val(priceFilterValues[0]);
$("#priceFilterMax").val(priceFilterValues[1]);
const sizeFilterValues = sizeSlider.noUiSlider.get();
$("#sizeFilterMin").val(sizeFilterValues[0]);
$("#sizeFilterMax").val(sizeFilterValues[1]);
<% if (hasGardenSize) { %>
const gardenSizeFilterValues = gardenSizeSlider.noUiSlider.get();
$("#gardenSizeFilterMin").val(gardenSizeFilterValues[0]);
$("#gardenSizeFilterMax").val(gardenSizeFilterValues[1]);
<% } %>
$("#filtersForm").submit();
});
}); });
</script> </script>

View File

@@ -3,18 +3,23 @@
<div class="center-align"> <div class="center-align">
<div class="row"> <div class="row">
<select class="segment-select" id="adType" name="adType"> <span class="segmented">
<option value="<%= AD_TYPE.AD_TYPE_SALE.id %>" <label>
<input type="radio" name="adType" value="<%= AD_TYPE.AD_TYPE_SALE.id %>"
<% if (selectedAdType === AD_TYPE.AD_TYPE_SALE.id) { %> <% if (selectedAdType === AD_TYPE.AD_TYPE_SALE.id) { %>
selected="selected" checked
<% } %> <% } %>>
><%= AD_TYPE.AD_TYPE_SALE.title %></option> <span class="label"><%= AD_TYPE.AD_TYPE_SALE.title %></span>
<option value="<%= AD_TYPE.AD_TYPE_RENT.id %>" </label>
<label>
<input type="radio" name="adType" value="<%= AD_TYPE.AD_TYPE_RENT.id %>"
<% if (selectedAdType === AD_TYPE.AD_TYPE_RENT.id) { %> <% if (selectedAdType === AD_TYPE.AD_TYPE_RENT.id) { %>
selected="selected" checked
<% } %> <% } %>>
><%= AD_TYPE.AD_TYPE_RENT.title %></option> <span class="label"><%= AD_TYPE.AD_TYPE_RENT.title %></span>
</select> </label>
</span>
</div> </div>
<br> <br>
@@ -37,45 +42,6 @@
</form> </form>
<script> <script>
(function($) {
$.fn.extend({
Segment: function() {
$(this).each(function() {
const self = $(this);
const onchange = self.attr('onchange');
const wrapper = $("<div>", { class: "ui-segment" });
$(this)
.find("option")
.each(function() {
const option = $("<span>", {
class: "option",
onclick: onchange,
text: $(this).text(),
value: $(this).val(),
});
if ($(this).is(":selected")) {
option.addClass("active");
}
wrapper.append(option);
});
wrapper.find("span.option").click(function (){
wrapper.find("span.option").removeClass("active");
$(this).addClass("active");
self.val($(this).attr('value'));
});
$(this).after(wrapper);
$(this).hide();
});
}
});
})(jQuery);
$(document).ready(() => {
$(".segment-select").Segment();
});
function saveAndSubmit(id) { function saveAndSubmit(id) {
$("#realEstateType").val(id); $("#realEstateType").val(id);
$("#realEstateTypeSelection > a").attr("onclick", ""); $("#realEstateTypeSelection > a").attr("onclick", "");

View File

@@ -2,11 +2,27 @@
<ul class="collection with-header"> <ul class="collection with-header">
<% for(const realEstate of realEstates) { %> <% for(const realEstate of realEstates) { %>
<li class="collection-item"> <li class="collection-item">
<div><%= realEstate.title %> <% if(realEstate.adStatus === AD_STATUS.STATUS_VIP) {%>
<a href="<%= realEstate.url %>" class="kivi-color secondary-content"> <div>
<% //This needs to do redirecting instead of direct link to realestate
%>
<a href="/redirect/<%= realEstate.id %>" class="estates-link">
<%= realEstate.title %>
<div class="kivi-color secondary-content">
<i class="material-icons">send</i> <i class="material-icons">send</i>
</div>
</a> </a>
</div> </div>
<%} else { %>
<div>
<a href="<%= realEstate.url %>" class="estates-link">
<%= realEstate.title %>
<div class="kivi-color secondary-content">
<i class="material-icons">send</i>
</div>
</a>
</div>
<% }%>
</li> </li>
<% } %> <% } %>
</ul> </ul>

View File

@@ -1,26 +1,49 @@
<br><br> <br /><br />
<div class="center"> <div class="center">
<div class="preloader-wrapper big active center"> <div class="preloader-wrapper big active center">
<div class="kivi-spinner-color spinner-layer spinner-green-only"> <div class="kivi-spinner-color spinner-layer spinner-green-only">
<div class="circle-clipper left"> <div class="circle-clipper left">
<div class="circle"></div> <div class="circle"></div>
</div><div class="gap-patch"> </div>
<div class="gap-patch">
<div class="circle"></div> <div class="circle"></div>
</div><div class="circle-clipper right"> </div>
<div class="circle-clipper right">
<div class="circle"></div> <div class="circle"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<br> <br />
<% if(vipAd) { %>
<div class="center"> <div class="center">
<h6> <h6>
<a href="<%= redirectUrl %>" rel="noreferrer" id="realEstateUrl">Kliknite ovdje ako Vas web preglednik ne preusmjeri automatski</a> Ovaj oglas zahtijeva da budete član
<a href="https://prostor.ba/" rel="noreferrer">Prostor.ba</a>.
<br />
<br />
<a href="https://prostor.ba/moj-prostor/prijava" rel="noreferrer"
>Ulogujte se</a
>
ili napravite
<a href="https://prostor.ba/moj-prostor/registracija" rel="noreferrer"
>novi račun</a
>, a potom otvorite <a href="<%= redirectUrl %>" rel="noreferrer">oglas</a>.
</h6> </h6>
</div> </div>
<% } else { %>
<div class="center">
<h6>
<a href="<%= redirectUrl %>" rel="noreferrer" id="realEstateUrl"
>Kliknite ovdje ako Vas web preglednik ne preusmjeri automatski</a
>
</h6>
</div>
<% }%>
<script> <script>
window.onload = () => { window.onload = function() {
document.getElementById('realEstateUrl').click(); document.getElementById("realEstateUrl").click();
} };
</script> </script>

View File

@@ -0,0 +1,204 @@
<br />
<div class="row center-align">
<h5>Cijena (KM)</h5>
<br /><br />
<div class="col s12">
<div class="center-align no-ui-slider" id="priceFilter"></div>
</div>
</div>
<br />
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input class="sliderInputBox" type="number" id="priceMin" name="priceMin" />
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input class="sliderInputBox" type="number" id="priceMax" name="priceMax" />
</div>
</div>
<br />
<p class="distinguished">
<label class="checkbox-label">
<input type="checkbox" class="filled-in" name="includeWithoutPrice"
checked
>
<span>Uključi i oglase bez cijene</span>
</label>
</p>
<br />
<div class="row center-align">
<h5>Površina (m<sup>2</sup>)</h5>
<br /><br />
<div class="col s12">
<div class="center-align no-ui-slider" id="sizeFilter"></div>
</div>
</div>
<br />
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input class="sliderInputBox" type="number" id="sizeMin" name="sizeMin" />
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input class="sliderInputBox" type="number" id="sizeMax" name="sizeMax" />
</div>
</div>
<br />
<% if(hasGardenSize) { %>
<div class="row center-align">
<h5>Površina okućnice (m<sup>2</sup>)</h5>
<br /><br />
<div class="col s12">
<div class="center-align no-ui-slider" id="gardenSizeFilter"></div>
</div>
</div>
<br />
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input
class="sliderInputBox"
type="number"
id="gardenSizeMin"
name="gardenSizeMin"
/>
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input
class="sliderInputBox"
type="number"
id="gardenSizeMax"
name="gardenSizeMax"
/>
</div>
</div>
<% } %>
<script>
$(document).ready(function() {
const priceSliderOptions = <%- priceSliderOptions %>;
const sizeSliderOptions = <%- sizeSliderOptions %>;
const priceStep = priceSliderOptions.step;
const sizeStep = sizeSliderOptions.step;
delete priceSliderOptions.step;
delete sizeSliderOptions.step;
function updatePriceInputs(values, handle, unencoded) {
$("#priceMin").val(Math.round(unencoded[0]/priceStep)*priceStep);
$("#priceMax").val(Math.round(unencoded[1]/priceStep)*priceStep);
}
function updateSizeInputs(values, handle, unencoded) {
$("#sizeMin").val(Math.round(unencoded[0]/sizeStep)*sizeStep);
$("#sizeMax").val(Math.round(unencoded[1]/sizeStep)*sizeStep);
}
const priceSlider = document.getElementById("priceFilter");
const sizeSlider = document.getElementById("sizeFilter");
const priceSliderObject = noUiSlider.create(priceSlider, priceSliderOptions);
const sizeSliderObject = noUiSlider.create(sizeSlider, sizeSliderOptions);
priceSliderObject.on('slide', updatePriceInputs);
sizeSliderObject.on('slide', updateSizeInputs);
function priceMinChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = priceSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
priceSliderObject.set([fixedNewValue, null]);
$("#priceMin").val(Math.round(priceSliderObject.get()[0]));
}
}
function priceMaxChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
priceSliderObject.set([null, newValue]);
$("#priceMax").val(Math.round(priceSliderObject.get()[1]));
}
}
$("#priceMin").val(priceSliderOptions.start[0]);
$("#priceMax").val(priceSliderOptions.start[1]);
$("#priceMin").change(priceMinChangeHandler);
$("#priceMax").change(priceMaxChangeHandler);
function sizeMinChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = sizeSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
sizeSliderObject.set([fixedNewValue, null]);
$("#sizeMin").val(Math.round(sizeSliderObject.get()[0]));
}
}
function sizeMaxChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
sizeSliderObject.set([null, newValue]);
$("#sizeMax").val(Math.round(sizeSliderObject.get()[1]));
}
}
$("#sizeMin").val(sizeSliderOptions.start[0]);
$("#sizeMax").val(sizeSliderOptions.start[1]);
$("#sizeMin").change(sizeMinChangeHandler);
$("#sizeMax").change(sizeMaxChangeHandler);
<% if(hasGardenSize) { %>
const gardenSizeSliderOptions = <%- gardenSizeSliderOptions %>;
const gardenSizeStep = gardenSizeSliderOptions.step;
delete gardenSizeSliderOptions.step;
function updateGardenSizeInputs(values, handle, unencoded) {
$("#gardenSizeMin").val(Math.round(unencoded[0]/gardenSizeStep)*gardenSizeStep);
$("#gardenSizeMax").val(Math.round(unencoded[1]/gardenSizeStep)*gardenSizeStep);
}
const gardenSizeSlider = document.getElementById("gardenSizeFilter");
const gardenSizeSliderObject = noUiSlider.create(gardenSizeSlider, gardenSizeSliderOptions);
gardenSizeSliderObject.on('slide', updateGardenSizeInputs);
function gardenSizeMinChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = gardenSizeSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
gardenSizeSliderObject.set([fixedNewValue, null]);
$("#gardenSizeMin").val(Math.round(gardenSizeSliderObject.get()[0]));
}
}
function gardenSizeMaxChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
gardenSizeSliderObject.set([null, newValue]);
$("#gardenSizeMin").val(Math.round(gardenSizeSliderObject.get()[0]));
}
}
$("#gardenSizeMin").val(gardenSizeSliderOptions.start[0]);
$("#gardenSizeMax").val(gardenSizeSliderOptions.start[1]);
$("#gardenSizeMin").change("step", gardenSizeMinChangeHandler);
$("#gardenSizeMax").change("step", gardenSizeMaxChangeHandler);
<% } %>
$("#submit").click(function() {
const priceFilterValues = priceSlider.noUiSlider.get();
$("#priceFilterMin").val(priceFilterValues[0]);
$("#priceFilterMax").val(priceFilterValues[1]);
const sizeFilterValues = sizeSlider.noUiSlider.get();
$("#sizeFilterMin").val(sizeFilterValues[0]);
$("#sizeFilterMax").val(sizeFilterValues[1]);
<% if (hasGardenSize) { %>
const gardenSizeFilterValues = gardenSizeSlider.noUiSlider.get();
$("#gardenSizeFilterMin").val(gardenSizeFilterValues[0]);
$("#gardenSizeFilterMax").val(gardenSizeFilterValues[1]);
<% } %>
$("#filtersForm").submit();
});
});
</script>

View File

@@ -1,4 +1,3 @@
<!-- -->
<br><br> <br><br>
<div class="row center-align"> <div class="row center-align">
<img src="assets/images/logo.svg" alt="kivi logo" width="160"> <img src="assets/images/logo.svg" alt="kivi logo" width="160">
@@ -8,8 +7,26 @@
<div> Na vaš email. </div> <div> Na vaš email. </div>
<div> BESPLATNO </div> <div> BESPLATNO </div>
</div> </div>
<form method="POST" name="welcomeForm">
<div class="row center-align"> <div class="row center-align">
<div class="col s6 push-s3"> <div class="col s5 m4 l3 push-s1 push-m2 push-l3">
<a href="<%= nextStep %>" class="welcome-center-button btn">Javi mi</a> <a href="#" onclick="saleClick()" class="welcome-center-button btn">Kupi</a>
</div>
<div class="col s5 m4 l3 push-s1 push-m2 push-l3">
<a href="#" onclick="rentClick()" class="welcome-center-button btn">Unajmi</a>
</div> </div>
</div> </div>
<input type="hidden" id="adType" name="adType">
</form>
<script>
function saleClick(){
$("#adType").val("<%= AD_TYPE.AD_TYPE_SALE.id %>");
document.welcomeForm.submit();
}
function rentClick(){
$("#adType").val("<%= AD_TYPE.AD_TYPE_RENT.id %>");
document.welcomeForm.submit();
}
</script>

View File

@@ -8,12 +8,24 @@ SEQUELIZE_LOGGING=0- no sequelize logging, 1- log to the console
PORT=Port for the app, defaults to 5000 PORT=Port for the app, defaults to 5000
APP_BASE_URL=base url for the app APP_BASE_URL=base url for the app
ENVIRONMENT=Variable to denote development, staging and production
USER_AGENT=User agent header to send in fetch requests
MAX_REAL_ESTATES_IN_EMAIL=Max number of real estates that will be shown in email, others will be truncated and URL with full list will be shwon MAX_REAL_ESTATES_IN_EMAIL=Max number of real estates that will be shown in email, others will be truncated and URL with full list will be shwon
MAX_REAL_ESTATES_IN_FIRST_EMAIL=Max number of real estates that will be shown in first (welcome) email MAX_REAL_ESTATES_IN_FIRST_EMAIL=Max number of real estates that will be shown in first (welcome) email
CHECK_UP_DAYS=Check up email is sent after this number of days without notification
#=============== GOOGLE ANALYTICS =============# #=============== GOOGLE ANALYTICS =============#
GA_ID=Google Analytics ID GA_ID=Google Analytics ID
#=============== GOOGLE MAPS =============#
API_MAP_KEY=(your-key-here)
#=============== SCRAPER API SUPORT =============#
USE_SCRAPER_API= To turn it on (1) or off (0)
SCRAPER_API_KEY= Key for Scraper api
#=============== AWS SDK EMAIL SETTINGS =======# #=============== AWS SDK EMAIL SETTINGS =======#
AWS_KEY_ID=(your-key-here) AWS_KEY_ID=(your-key-here)
AWS_SECRET_ACCESS_KEY=(your-key-here) AWS_SECRET_ACCESS_KEY=(your-key-here)
@@ -31,6 +43,7 @@ OLX_CRAWLER_AD_TYPE=enum name of what type of ads should be crawled, check commo
OLX_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values OLX_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values
OLX_IGNORED_USERNAMES=comma separated list of usernames to ignore OLX_IGNORED_USERNAMES=comma separated list of usernames to ignore
OLX_DELAY_BETWEEN_PAGES=time in miliseconds to wait before indexing next page OLX_DELAY_BETWEEN_PAGES=time in miliseconds to wait before indexing next page
OLX_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found
#==RENTAL== #==RENTAL==
RENTAL_MAX_PAGES=Restrict crawler to this number of pages RENTAL_MAX_PAGES=Restrict crawler to this number of pages
RENTAL_MAX_RESULTS_PER_PAGE=Only this number or less results from one page will be scraped and saved RENTAL_MAX_RESULTS_PER_PAGE=Only this number or less results from one page will be scraped and saved
@@ -38,13 +51,17 @@ RENTAL_CRAWLER_AD_TYPE=enum name of what type of ads should be crawled, check co
RENTAL_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values RENTAL_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values
RENTAL_IGNORED_USERNAMES=!!! This is not used for rental crawler !!! RENTAL_IGNORED_USERNAMES=!!! This is not used for rental crawler !!!
RENTAL_DELAY_BETWEEN_PAGES=time in miliseconds to wait before indexing next page RENTAL_DELAY_BETWEEN_PAGES=time in miliseconds to wait before indexing next page
RENTAL_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found
#==PROSTOR== #==PROSTOR==
PROSTOR_MAX_PAGES=!!! This is not used for prostor crawler !!! PROSTOR_MAX_PAGES=!!! This is not used for prostor crawler !!!
PROSTOR_MAX_RESULTS_PER_PAGE=For Prostor crawler, this represents MAX RESULTS in total PROSTOR_MAX_RESULTS_PER_PAGE=For Prostor crawler, this represents how many ads are crawled at once
PROSTOR_CRAWLER_AD_TYPE=enum name of what type of ads should be crawled, check common/enums.js file for valid values PROSTOR_CRAWLER_AD_TYPE=enum name of what type of ads should be crawled, check common/enums.js file for valid values
PROSTOR_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values PROSTOR_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values
PROSTOR_IGNORED_USERNAMES=!!! This is not used for prostor crawler !!! PROSTOR_IGNORED_USERNAMES=!!! This is not used for prostor crawler !!!
PROSTOR_DELAY_BETWEEN_PAGES=!!! This is not used for prostor crawler !!! PROSTOR_DELAY_BETWEEN_PAGES=!!! This is not used for prostor crawler !!!
PROSTOR_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found
PROSTOR_LOGIN_EMAIL=Email of valid Prostor.ba account for crawling purposes
PROSTOR_LOGIN_PASS=Password of valid Prostor.ba account for crawling purposes
#==AKTIDO== #==AKTIDO==
AKTIDO_MAX_PAGES=Restrict crawler to this number of pages AKTIDO_MAX_PAGES=Restrict crawler to this number of pages
AKTIDO_MAX_RESULTS_PER_PAGE=Only this number or less results from one page will be scraped and saved AKTIDO_MAX_RESULTS_PER_PAGE=Only this number or less results from one page will be scraped and saved
@@ -52,3 +69,9 @@ AKTIDO_CRAWLER_AD_TYPE=enum name of what type of ads should be crawled, check co
AKTIDO_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values AKTIDO_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values
AKTIDO_IGNORED_USERNAMES=!!! This is not used for aktido crawler !!! AKTIDO_IGNORED_USERNAMES=!!! This is not used for aktido crawler !!!
AKTIDO_DELAY_BETWEEN_PAGES=time in miliseconds to wait before indexing next page AKTIDO_DELAY_BETWEEN_PAGES=time in miliseconds to wait before indexing next page
AKTIDO_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found
#==SALJIC NEKRETNINE==
SALJIC_MAX_RESULTS_PER_PAGE=For Saljic crawler, this represents how many ads are crawled at once
SALJIC_CRAWLER_AD_TYPE=enum name of what type of ads should be crawled, check common/enums.js file for valid values
SALJIC_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values
SALJIC_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found

View File

@@ -12,6 +12,7 @@ const {
} = require("./app/config/appConfig"); } = require("./app/config/appConfig");
const routes = require("./app/routes"); const routes = require("./app/routes");
const { crawlAll } = require("./app/crawler/crawl"); const { crawlAll } = require("./app/crawler/crawl");
const { checkUpNotify } = require("./app/services/notificationService");
const { const {
notifyForNewRealEstates notifyForNewRealEstates
} = require("./app/services/notificationService"); } = require("./app/services/notificationService");
@@ -45,4 +46,7 @@ const crawl = () => {
}); });
} }
}; };
setInterval(crawl, CRAWLER_INTERVAL * 1000); setInterval(crawl, CRAWLER_INTERVAL * 1000);
setInterval(checkUpNotify, 1000 * 60 * 60 * 24);

97
package-lock.json generated
View File

@@ -1052,6 +1052,14 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
}, },
"eslint-plugin-prettier": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.2.tgz",
"integrity": "sha512-GlolCC9y3XZfv3RQfwGew7NnuFDKsfI4lbvRK+PIIo23SFH+LemGs4cKwzAaRa+Mdb+lQO/STaIayno8T5sJJA==",
"requires": {
"prettier-linter-helpers": "^1.0.0"
}
},
"etag": { "etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -1271,6 +1279,11 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk="
}, },
"fast-diff": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
"integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w=="
},
"fast-json-stable-stringify": { "fast-json-stable-stringify": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz",
@@ -1333,13 +1346,23 @@
"integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE="
}, },
"form-data": { "form-data": {
"version": "2.3.3", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==",
"requires": { "requires": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.6", "combined-stream": "^1.0.8",
"mime-types": "^2.1.12" "mime-types": "^2.1.12"
},
"dependencies": {
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
}
} }
}, },
"forwarded": { "forwarded": {
@@ -1396,7 +1419,8 @@
"ansi-regex": { "ansi-regex": {
"version": "2.1.1", "version": "2.1.1",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
@@ -1417,12 +1441,14 @@
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@@ -1437,17 +1463,20 @@
"code-point-at": { "code-point-at": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"console-control-strings": { "console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"core-util-is": { "core-util-is": {
"version": "1.0.2", "version": "1.0.2",
@@ -1564,7 +1593,8 @@
"inherits": { "inherits": {
"version": "2.0.3", "version": "2.0.3",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"ini": { "ini": {
"version": "1.3.5", "version": "1.3.5",
@@ -1576,6 +1606,7 @@
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"number-is-nan": "^1.0.0" "number-is-nan": "^1.0.0"
} }
@@ -1590,6 +1621,7 @@
"version": "3.0.4", "version": "3.0.4",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }
@@ -1597,12 +1629,14 @@
"minimist": { "minimist": {
"version": "0.0.8", "version": "0.0.8",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"minipass": { "minipass": {
"version": "2.3.5", "version": "2.3.5",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"safe-buffer": "^5.1.2", "safe-buffer": "^5.1.2",
"yallist": "^3.0.0" "yallist": "^3.0.0"
@@ -1621,6 +1655,7 @@
"version": "0.5.1", "version": "0.5.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"minimist": "0.0.8" "minimist": "0.0.8"
} }
@@ -1701,7 +1736,8 @@
"number-is-nan": { "number-is-nan": {
"version": "1.0.1", "version": "1.0.1",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
@@ -1713,6 +1749,7 @@
"version": "1.4.0", "version": "1.4.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"wrappy": "1" "wrappy": "1"
} }
@@ -1798,7 +1835,8 @@
"safe-buffer": { "safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"safer-buffer": { "safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
@@ -1834,6 +1872,7 @@
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"code-point-at": "^1.0.0", "code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0", "is-fullwidth-code-point": "^1.0.0",
@@ -1853,6 +1892,7 @@
"version": "3.0.1", "version": "3.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"ansi-regex": "^2.0.0" "ansi-regex": "^2.0.0"
} }
@@ -1896,12 +1936,14 @@
"wrappy": { "wrappy": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"yallist": { "yallist": {
"version": "3.0.3", "version": "3.0.3",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
} }
} }
}, },
@@ -3160,6 +3202,19 @@
"integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=", "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=",
"dev": true "dev": true
}, },
"prettier": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew=="
},
"prettier-linter-helpers": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
"requires": {
"fast-diff": "^1.1.2"
}
},
"process-nextick-args": { "process-nextick-args": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
@@ -3385,6 +3440,18 @@
"tough-cookie": "~2.4.3", "tough-cookie": "~2.4.3",
"tunnel-agent": "^0.6.0", "tunnel-agent": "^0.6.0",
"uuid": "^3.3.2" "uuid": "^3.3.2"
},
"dependencies": {
"form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
}
}
} }
}, },
"require-directory": { "require-directory": {

View File

@@ -8,12 +8,16 @@
"start": "node ./index.js", "start": "node ./index.js",
"start-mon": "nodemon ./index.js", "start-mon": "nodemon ./index.js",
"migrate": "cd app && npx sequelize db:migrate", "migrate": "cd app && npx sequelize db:migrate",
"setup": "docker build -t marketalerts . && docker run -e POSTGRES_USER=docker -e POSTGRES_PASSWORD=docker -e POSTGRES_DB=marketalerts --name pg_marketalerts -d -p 5432:5432 marketalerts && sleep 4 && npm run migrate", "seed": "cd app && npx sequelize db:seed:all",
"setup": "docker build -t marketalerts . && docker run -e POSTGRES_USER=docker -e POSTGRES_PASSWORD=docker -e POSTGRES_DB=marketalerts --name pg_marketalerts -d -p 5432:5432 marketalerts && sleep 10 && npm run migrate",
"docker-start": "docker start pg_marketalerts", "docker-start": "docker start pg_marketalerts",
"docker-stop": "docker stop pg_marketalerts", "docker-stop": "docker stop pg_marketalerts",
"crawl": "cd app/crawler && node npmCrawl.js", "crawl": "cd app/crawler && node npmCrawl.js",
"daily-notify": "cd app/npmScripts && node npmDailyNotify.js",
"checkup-notify": "cd app/npmScripts && node npmCheckUpNotify.js",
"test-search": "cd test && node searchTest.js", "test-search": "cd test && node searchTest.js",
"test-olx-scraper": "cd test && node olxScrapeTest.js" "test-olx-scraper": "cd test && node olxScrapeTest.js",
"test-rental-scraper": "cd test && node rentalScrapeTest.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -33,15 +37,18 @@
"compression": "^1.7.4", "compression": "^1.7.4",
"dotenv": "^7.0.0", "dotenv": "^7.0.0",
"ejs": "^2.6.1", "ejs": "^2.6.1",
"eslint-plugin-prettier": "^3.1.2",
"express": "^4.16.4", "express": "^4.16.4",
"express-ejs-layouts": "^2.5.0", "express-ejs-layouts": "^2.5.0",
"express-layout": "^0.1.0", "express-layout": "^0.1.0",
"form-data": "^3.0.0",
"html-to-text": "^5.1.1", "html-to-text": "^5.1.1",
"moment": "^2.24.0", "moment": "^2.24.0",
"moment-timezone": "^0.5.26", "moment-timezone": "^0.5.26",
"node-fetch": "^2.3.0", "node-fetch": "^2.3.0",
"node-schedule": "^1.3.2", "node-schedule": "^1.3.2",
"pg": "^7.10.0", "pg": "^7.10.0",
"prettier": "^1.19.1",
"react-step-wizard": "^5.1.0", "react-step-wizard": "^5.1.0",
"sequelize": "^5.18.4", "sequelize": "^5.18.4",
"sequelize-cli": "^5.5.0" "sequelize-cli": "^5.5.0"

17
test/rentalScrapeTest.js Normal file
View File

@@ -0,0 +1,17 @@
"use strict";
const rentalCrawler = require("../app/crawler/specificCrawlers/rental");
const urlToScrape = process.argv[2] || undefined;
if (urlToScrape) {
const crawler = new rentalCrawler();
(async () => {
const data = await crawler.scrapeAd(urlToScrape);
console.log(data);
})();
} else {
console.log("No URL to scrape. Use like this : ");
console.log("npm run test-rental-scraper -- URL_TO_SCRAPE");
}