Compare commits

...

250 Commits

Author SHA1 Message Date
Bilal Catic
026d7cded7 change price slider options for renting option 2019-10-31 19:06:44 +01:00
Bilal Catic
9612b28c91 disable real estate type selection after first click 2019-10-31 19:06:44 +01:00
Bilal Catic
aab32fc608 disable button on first click 2019-10-31 19:06:44 +01:00
Bilal Catic
d32b98bb7b implement Rent option on the frontend 2019-10-31 19:06:44 +01:00
Bilal Catic
5817964b50 remove disabled css 2019-10-31 19:06:44 +01:00
Bilal Catic
59565885cb extend AD_TPYE in db model 2019-10-31 19:06:44 +01:00
Bilal Catic
127691f7bb extend AD_TYPE enum 2019-10-31 19:06:44 +01:00
Bilal Catic
4318fa8a2d extend AD_TYPE enum in specific crawler files 2019-10-31 19:06:44 +01:00
Bilal Catic
6261408a59 Merge branch 'implement-renting-option' into 'master'
Implement renting option - crawler part

See merge request saburly/marketalarm/web!64
2019-10-31 18:05:54 +00:00
Bilal Catic
ecc5b174a0 implement RENT option for Aktido; implement force crawl option 2019-10-30 17:23:43 +01:00
Bilal Catic
fa712ce97d implement RENT option for Rental; implement force crawl option 2019-10-30 15:53:11 +01:00
Bilal Catic
3abbed183e implement RENT and REQUEST option for OLX; implement force crawl option 2019-10-30 15:03:59 +01:00
Bilal Catic
97d93a3f37 add force crawl ENV option for OLX 2019-10-30 15:02:54 +01:00
Bilal Catic
3bb67a4db9 add REQUEST category 2019-10-30 15:02:31 +01:00
Bilal Catic
f181450aa9 fix slider box input - handle one input grater/smaller than the other 2019-10-30 11:23:07 +01:00
Bilal Catic
caec7b6554 Merge branch 'add-textbox-input-for-sliders' into 'master'
add input box for sliders

See merge request saburly/marketalarm/web!63
2019-10-30 09:56:58 +00:00
Bilal Catic
9033114545 add input box for sliders 2019-10-30 10:54:05 +01:00
Bilal Catic
cbbed137e6 Merge branch 'fix-invalid-email-crash' into 'master'
Fix invalid email crash

See merge request saburly/marketalarm/web!62
2019-10-30 09:01:38 +00:00
Bilal Catic
dd8e4d77ed improve email regex; improve error handling for query review 2019-10-28 12:34:14 +01:00
Bilal Catic
43877820cf validate real estate type selection 2019-10-28 10:59:08 +01:00
Bilal Catic
c6aeef10e8 Merge branch 'add-aktido-crawler' into 'master'
Add aktido crawler

See merge request saburly/marketalarm/web!60
2019-10-28 09:47:31 +00:00
Bilal Catic
1e36cb8423 add ALL category option for Rental agency 2019-10-28 09:24:08 +01:00
Bilal Catic
2c2fcd648f remove scrapeAd logging 2019-10-28 09:23:51 +01:00
Bilal Catic
5b6886f52b add ALL categories option for Aktido agency 2019-10-28 09:20:03 +01:00
Bilal Catic
f899c96dc6 add crawler and crawler config for Aktido agency 2019-10-28 09:14:45 +01:00
Bilal Catic
f5d912f02c Merge branch 'add-crawler-for-prostor-page' into 'master'
Add crawler for prostor page

See merge request saburly/marketalarm/web!59
2019-10-25 10:21:24 +00:00
Bilal Catic
747ebb88e5 add debugging log switch for crawler process 2019-10-25 11:08:52 +02:00
Bilal Catic
7e3b0bfcd5 implement crawler for Prostor agency 2019-10-25 10:54:08 +02:00
Bilal Catic
05fad652c4 add PROSTOR agency enum; update ENV template 2019-10-25 10:53:44 +02:00
Bilal Catic
5098b08b3f add ALL option to crawler cat, exclude from real estate types list 2019-10-24 17:43:14 +02:00
Bilal Catic
6fc4218e39 add config files for Prostor agency 2019-10-24 17:11:12 +02:00
Bilal Catic
935ae60ae1 move specific crawler config to the separated files 2019-10-24 16:57:23 +02:00
Bilal Catic
e82a0cfba4 Merge branch 'add-crawler-for-rental-page' into 'master'
Add crawler for rental page

See merge request saburly/marketalarm/web!58
2019-10-24 13:20:40 +00:00
Bilal Catic
2064d40985 stop "rental" crawler if there are no new real estates on the page 2019-10-24 11:26:11 +02:00
Bilal Catic
a6336b7d27 implement crawler for "rental" agency 2019-10-24 11:26:11 +02:00
Bilal Catic
ec798fe94c add crawler config and include specific crawler for "rental" agency 2019-10-24 11:26:11 +02:00
Bilal Catic
abc591749e Merge branch 'update-success-page' into 'master'
Update success page

See merge request saburly/marketalarm/web!57
2019-10-24 05:38:10 +00:00
Bilal Catic
d344d939bb move android GIF to the center 2019-10-24 07:37:18 +02:00
Bilal Catic
d4aec2f643 show both GIF instructions on desktop, but only android gif for mobile 2019-10-24 07:32:40 +02:00
Bilal Catic
7b02f3225b add GIF instructions on success page 2019-10-24 07:01:45 +02:00
Bilal Catic
617cf43bca Merge branch 'improve-social-media-sharing-preview' into 'master'
Improve social media sharing preview

See merge request saburly/marketalarm/web!56
2019-10-21 08:50:44 +00:00
Bilal Catic
8a217cc377 add meta tags for better social media sharing link preview 2019-10-21 10:50:27 +02:00
Bilal Catic
b1ec1a030f Merge branch 'add-renting-soon-option' into 'master'
add segmented control for ad type selection

See merge request saburly/marketalarm/web!55
2019-10-21 08:49:55 +00:00
Bilal Catic
3830c5f257 add "uskoro" text to the renting option 2019-10-21 10:08:12 +02:00
Bilal Catic
d10540c631 add segmented control for ad type selection 2019-10-21 08:05:13 +02:00
Bilal Catic
9dcb27291b Merge branch 'move-locate-me-button' into 'master'
Move locate me button

See merge request saburly/marketalarm/web!54
2019-10-18 12:35:52 +00:00
=
689a3cfadf Run prettier 2019-10-18 05:33:03 -07:00
=
41acb75b81 Fix problems with button color 2019-10-18 05:31:13 -07:00
Bilal Catic
6a2cb18cf1 Merge branch 'move-locate-me-button' into 'master'
Fixed a couple of things like (locate me, location edit)

See merge request saburly/marketalarm/web!53
2019-10-18 12:09:50 +00:00
=
9b830bdd01 Fixed a couple of things like (locate me, location edit) 2019-10-18 03:29:15 -07:00
Bilal Catic
bfb510252b Merge branch 'remove-obsolete-files' into 'master'
Remove obsolete files

See merge request saburly/marketalarm/web!52
2019-10-18 08:19:10 +00:00
Bilal Catic
67a7223a3a add OLX scraper test NPM script 2019-10-18 09:14:08 +02:00
Bilal Catic
2baf26db1c delete obsolete files 2019-10-18 09:13:39 +02:00
Edin
8654f0be58 Merge branch 'minor-fixes-and-favicon' into 'master'
Added favicon and other CSS fixes (color, header padding)

See merge request saburly/marketalarm/web!51
2019-10-17 13:15:52 +00:00
=
5c8fe05e36 Added favicon and other CSS fixes (color, header padding) 2019-10-17 06:05:18 -07:00
Bilal Catic
9341b72f6f Merge branch 'improve-slider-ranges' into 'master'
Improve slider ranges

See merge request saburly/marketalarm/web!50
2019-10-16 13:35:19 +00:00
Bilal Catic
8e5bc199a5 increase garage max size 2019-10-16 15:33:25 +02:00
Bilal Catic
8fca523bb5 change slider ranges for land and garage 2019-10-16 15:09:05 +02:00
Bilal Catic
a9109835fb hide forceSSL logs 2019-10-16 15:08:45 +02:00
Bilal Catic
d2513bca9f Merge branch 'force-ssl' into 'master'
redirect http to https

See merge request saburly/marketalarm/web!49
2019-10-16 10:54:38 +00:00
Bilal Catic
0fd6e88496 redirect http to https 2019-10-16 12:49:58 +02:00
Bilal Catic
69ed16af95 Merge branch 'add-logo-with-name' into 'master'
use logo with text

See merge request saburly/marketalarm/web!48
2019-10-16 09:07:29 +00:00
Bilal Catic
06d678dda5 use logo with text 2019-10-16 11:05:14 +02:00
Bilal Catic
62ef705341 Merge branch 'remove-location-placeholder' into 'master'
remove location placeholder; fix typo in email notification

See merge request saburly/marketalarm/web!47
2019-10-15 21:36:07 +00:00
Bilal Catic
e17f62dd6f remove location placeholder; fix typo in email notification 2019-10-15 23:35:41 +02:00
Bilal Catic
792ada2380 Merge branch 'change-logo' into 'master'
Change logo

See merge request saburly/marketalarm/web!46
2019-10-15 20:09:37 +00:00
Bilal Catic
384b4a356b fix redirect and unsubscribe for invalid id 2019-10-15 21:50:45 +02:00
Bilal Catic
247fe4d35c remove unused css rule 2019-10-15 21:50:45 +02:00
Bilal Catic
c949dfac21 change logo and real estate type selection color 2019-10-15 21:50:45 +02:00
Bilal Catic
0a32e0998b Merge branch 'improve-clickable-items' into 'master'
Improve clickable items

See merge request saburly/marketalarm/web!45
2019-10-15 19:38:47 +00:00
Bilal Catic
205892112d add waves effect to the real estate type selection items 2019-10-15 18:56:52 +02:00
Bilal Catic
acdeca94df redirect to Not Found page when search request param is missing from URL 2019-10-15 18:51:19 +02:00
Bilal Catic
566f9c4345 fix null value for garden size when real estate type is changed 2019-10-15 18:46:11 +02:00
Bilal Catic
2a1361733f change real estate type selection design 2019-10-15 18:34:34 +02:00
Bilal Catic
e0a1444b55 show error page when no search request is found 2019-10-14 11:30:52 +02:00
Bilal Catic
25ee3bc16c Merge branch 'modify-css-to-accomodate-KIVI-design' into 'master'
Modify css to accomodate kivi design

See merge request saburly/marketalarm/web!44
2019-10-14 09:18:21 +00:00
Bilal Catic
362ee0dbb2 fix query review empty email bug 2019-10-14 10:56:01 +02:00
Bilal Catic
418b8be746 apply kivi design 2019-10-14 10:52:32 +02:00
Bilal Catic
8f0b18a4d7 Merge branch 'add-other-olx-real-estate-categories' into 'master'
Add other olx real estate categories

See merge request saburly/marketalarm/web!43
2019-10-14 07:28:10 +00:00
Bilal Catic
dde56ef139 show email on query review page if it is already saved 2019-10-14 09:27:32 +02:00
Bilal Catic
60395078e6 fix how filters are loaded and shown on the filter and query review page 2019-10-14 09:27:32 +02:00
Bilal Catic
88e7cac420 allow olx crawler to recognize other OLX categories 2019-10-14 09:27:32 +02:00
Bilal Catic
9fc5072632 include other olx real estate categories in enums and configs 2019-10-14 09:27:32 +02:00
Bilal Catic
722d549393 Merge branch 'select-first-result-from-geolocation-dropdown-with-enter' into 'master'
Select first result from geolocation dropdown with enter

See merge request saburly/marketalarm/web!42
2019-10-14 07:26:47 +00:00
Bilal Catic
093571e8c5 Merge branch 'modify-notification-email-subject' into 'master'
modify notification email subject

See merge request saburly/marketalarm/web!41
2019-10-14 07:26:31 +00:00
Bilal Catic
3f72ffba89 change range filters from non-linear to linear 2019-10-13 23:32:09 +02:00
Bilal Catic
105ed47276 select first option from google autocomplete for location input 2019-10-13 22:47:07 +02:00
Bilal Catic
0a7aaec4c6 modify notification email subject 2019-10-11 23:10:28 +02:00
Bilal Catic
c0cbaf5b73 Merge branch 'combine-filters-on-one-page' into 'master'
Combine filters on one page

See merge request saburly/marketalarm/web!40
2019-10-11 15:25:57 +00:00
Bilal Catic
1dbe1da802 fix default range filter values 2019-10-11 15:45:50 +02:00
Bilal Catic
164510c8fc fix next button width 2019-10-11 15:45:29 +02:00
Bilal Catic
3251aca4e7 move all filters to one page 2019-10-11 15:37:47 +02:00
Bilal Catic
ef3d97612b remove .idea directory 2019-10-10 19:50:44 +02:00
Bilal Catic
73a39bcd0a control Google Analytics ID with ENV variable 2019-10-10 17:39:13 +02:00
Bilal Catic
549ab4e5e4 Merge branch 'fix-nav-bar-width' into 'master'
move navbar outside of the container

See merge request saburly/marketalarm/web!39
2019-10-10 12:32:36 +00:00
Bilal Catic
e2b72628a8 move navbar outside of the container 2019-10-10 13:54:18 +02:00
Bilal Catic
19a5c914aa Merge branch 'add-google-analytics' into 'master'
Add google analytics

See merge request saburly/marketalarm/web!38
2019-10-10 08:35:16 +00:00
Bilal Catic
73a792862b remove email sending logs 2019-10-10 00:59:12 +02:00
Bilal Catic
0818fcecd2 remove crawler and saver logging 2019-10-10 00:59:12 +02:00
Bilal Catic
40fb1e6ad7 create custom view for redirect and custom view for not found 2019-10-10 00:59:12 +02:00
Bilal Catic
20a93b7fdc add google analytics to the wizard steps 2019-10-10 00:59:12 +02:00
Bilal Catic
b17d97b40e Merge branch 'move-email-entry-to-the-review-page' into 'master'
Move email entry to the review page

See merge request saburly/marketalarm/web!37
2019-10-09 13:42:33 +00:00
Bilal Catic
778197b32f Merge branch 'add-notification-on-new-search-request' into 'master'
find matching real estates for new search request and notify

See merge request saburly/marketalarm/web!36
2019-10-09 13:41:57 +00:00
Bilal Catic
72864f3a37 move email input to the review step 2019-10-08 20:42:54 +02:00
Bilal Catic
615aed65b6 find matching real estates for new search request and notify 2019-10-07 20:49:54 +02:00
Bilal Catic
502c7c3e35 fix error on email validation failure 2019-09-30 19:33:01 +02:00
Bilal Catic
3e5371f780 Merge branch 'notification-service' into 'master'
Notification service

See merge request saburly/marketalarm/web!35
2019-09-30 14:23:39 +00:00
Bilal Catic
bbff526ea0 skip real estates without location in search 2019-09-30 14:27:26 +02:00
Bilal Catic
5e8e13a984 fix enums 2019-09-30 14:27:01 +02:00
Bilal Catic
37ba7e2c8c pass new real estates to the notification service 2019-09-30 13:53:35 +02:00
Bilal Catic
99a6cefd89 fix real estates list 2019-09-30 13:24:07 +02:00
Bilal Catic
0fdef956a7 use sequelize include option when querying real estates from matches 2019-09-30 13:20:38 +02:00
Bilal Catic
4b5548aa96 ad AS to DB query 2019-09-30 13:20:11 +02:00
Bilal Catic
b703d55c06 fix model associations 2019-09-30 13:17:27 +02:00
Bilal Catic
4713e78d51 remove obsolete DB helper functions 2019-09-30 12:08:37 +02:00
Bilal Catic
6272ec0f47 use matching service to find matches 2019-09-30 12:08:16 +02:00
Bilal Catic
dac7043a56 add service to find real estate - search request matches 2019-09-30 12:07:47 +02:00
Bilal Catic
12f826ef55 add methods to add and find real estate - search request matches 2019-09-30 12:07:21 +02:00
Bilal Catic
5a7b9826c2 refactor email content 2019-09-30 12:06:35 +02:00
Bilal Catic
b3ea2398d4 add more real estates to test 2019-09-30 10:31:50 +02:00
Bilal Catic
af36653c1a refactor redirect - use new method to find real estate by id 2019-09-30 10:31:07 +02:00
Bilal Catic
31ffc3253f add method to find search requests for real estate 2019-09-30 10:30:13 +02:00
Bilal Catic
54f7e4ba53 add method to find real estate by id 2019-09-30 10:29:27 +02:00
Bilal Catic
87a72be8fe use notificationService to send email for new search request 2019-09-30 10:29:05 +02:00
Bilal Catic
a738a371ae add notificationService to handle when, what and to whom to send email 2019-09-30 10:28:29 +02:00
Bilal Catic
9c0104a57c refactor crawler - adapt to use new ENUM objects 2019-09-30 10:27:12 +02:00
Bilal Catic
e1dfd6a4eb add helper to generate email; install html-to-text package 2019-09-30 09:51:10 +02:00
Bilal Catic
68ef31f867 add emailService to handle email sending 2019-09-30 09:49:47 +02:00
Bilal Catic
e3e47345bc load AWS config through app config; fix ENV path 2019-09-30 09:44:19 +02:00
Bilal Catic
c57d3f8457 add npm command to test matching and notification services 2019-09-29 19:28:47 +02:00
Bilal Catic
6d7366f2c2 use single ENUMS; change old realEstateType enums 2019-09-27 19:36:20 +02:00
Bilal Catic
d8d801d314 use single ENUMS; change old realEstateType enums 2019-09-27 18:20:16 +02:00
Bilal Catic
f31355dd84 use AD_TYPE enums for SearchRequest adType field 2019-09-27 16:49:10 +02:00
Bilal Catic
ad873d61e7 Merge branch 'start-crawler-loop-with-server' into 'master'
start crawler loop when server is started

See merge request saburly/marketalarm/web!34
2019-09-27 09:22:17 +00:00
Bilal Catic
c5a720484a add ENV variable to control crawler execution 2019-09-26 23:55:34 +02:00
Bilal Catic
2e92f961ff start crawler loop when server is started 2019-09-26 17:30:06 +02:00
Bilal Catic
0b083a02e2 Merge branch 'make-crawler-smarter' into 'master'
Make crawler smarter

See merge request saburly/marketalarm/web!33
2019-09-25 17:15:14 +00:00
Bilal Catic
3d203df988 remove comment from delay between indexing pages 2019-09-25 10:00:42 +00:00
Bilal Catic
c9a959f8be stop crawling when existing, not renewed ad is found 2019-09-25 08:55:00 +02:00
Bilal Catic
b3fcc6ba9a return new and existing real estates when saving results 2019-09-25 08:55:00 +02:00
Bilal Catic
f93d0e738f add delay between pages config variable 2019-09-25 08:55:00 +02:00
Bilal Catic
90bc57edb6 stop crawling when existing, non-renewed ad is found 2019-09-25 08:55:00 +02:00
Bilal Catic
746732f30b remove deleted column from RealEstate model 2019-09-25 08:55:00 +02:00
Bilal Catic
06d35fcb4b move ignored usernames config to crawler specific config 2019-09-25 08:55:00 +02:00
Bilal Catic
63eb64b0f6 parse and save published and renewed dates 2019-09-25 08:55:00 +02:00
Bilal Catic
c7184be5fc install moment and moment-timezone packages 2019-09-25 08:55:00 +02:00
Bilal Catic
18db554ea8 add published and renewed date columns to the RealEstates table 2019-09-25 08:55:00 +02:00
Bilal Catic
3140fdf0c0 use function generator to index pages; crawl in parallel 2019-09-25 08:55:00 +02:00
Bilal Catic
c4f6c6e1c3 construct crawling url before indexing single page 2019-09-25 08:55:00 +02:00
Edin Dazdarevic
5f1697f6ae Set viewport correctly 2019-09-21 19:44:22 +02:00
Bilal Catic
7845587fa7 Merge branch 'go_again_copy' into 'master'
Change final screen to explain to user what they can expect next

See merge request saburly/marketalarm/web!32
2019-09-21 15:58:20 +00:00
Bilal Catic
c2d1725173 Merge branch 'map_screen_copy' into 'master'
Add copy that explains the map screen better.

See merge request saburly/marketalarm/web!31
2019-09-21 14:42:32 +00:00
Bilal Catic
e3636451b7 fix typo 2019-09-21 14:41:20 +00:00
Senad Uka
0f01a070ca Change final screen to explain to user what they can expect next 2019-09-21 08:14:04 +02:00
Senad Uka
bfcc0a09d9 Add copy that explains the map screen better. 2019-09-21 06:06:39 +02:00
Bilal Catic
51411a4109 Merge branch 'switch-to-new-crawler' into 'master'
Switch to new crawler

See merge request saburly/marketalarm/web!30
2019-09-18 14:06:16 +00:00
Bilal Catic
f9cd26fca8 refactor enums 2019-09-18 15:33:58 +02:00
Bilal Catic
e374cac60e update RealEstates model, add DB helper functions for RealEstates model 2019-09-18 15:33:42 +02:00
Bilal Catic
d3b2f95719 add new ENV variables to control crawler 2019-09-18 15:33:09 +02:00
Bilal Catic
3d46c82d3d create new crawler and Postgres saver 2019-09-18 15:32:48 +02:00
Bilal Catic
2e13763939 update Sequelize 2019-09-18 06:35:39 +02:00
Bilal Catic
2702259eb1 change title, short and long description columns type to Text 2019-09-18 06:35:26 +02:00
Bilal Catic
17461b2e57 remove deleted and sold columns, adStatus is replacement column 2019-09-17 10:27:25 +02:00
Bilal Catic
5cb12b1714 remove lastTimeCrawled column - use updatedAt for this 2019-09-17 10:12:49 +02:00
Bilal Catic
b96ce17dbf add unique constraint - composite key to the RealEstates table 2019-09-17 09:07:14 +02:00
Bilal Catic
88ee2ee326 add adStatus column to the RealEstates table 2019-09-16 23:20:18 +02:00
Bilal Catic
33b91c8161 add short and long description columns to the RealEstates table 2019-09-16 23:09:29 +02:00
Bilal Catic
25faf75d6f add title column to the RealEstates table 2019-09-16 16:08:29 +02:00
Bilal Catic
76a989fa37 replace old crawler, without specific crawler and saver implementation 2019-09-16 15:59:53 +02:00
Bilal Catic
b28b160852 fix locate me button position 2019-09-16 11:56:55 +02:00
Bilal Catic
dbb9807e63 Merge branch 'redesign-DB-and-adapt-search-request' into 'master'
Redesign db and adapt search request

See merge request saburly/marketalarm/web!29
2019-09-13 16:55:11 +00:00
Bilal Catic
5dfe363adc adapt unsubscribe step for new DB design 2019-09-13 14:54:21 +02:00
Bilal Catic
2b22cd04fd fix unsubscribe and review links in email notification 2019-09-13 14:50:23 +02:00
Bilal Catic
c01cb03762 adapt query submit size step for new DB design 2019-09-13 14:40:10 +02:00
Bilal Catic
b4c71b93de adapt garden size step for new DB design 2019-09-13 14:30:54 +02:00
Bilal Catic
e26c2b6e8d adapt price and query review steps for new DB design 2019-09-13 14:17:46 +02:00
Bilal Catic
ff68e96f4f adapt size step for new DB design 2019-09-13 13:58:58 +02:00
Bilal Catic
db58d1e98b adapt location step for new DB design 2019-09-13 12:37:53 +02:00
Bilal Catic
fbf7aabe93 use sequelize findByPk method for getting SearchRequest object 2019-09-13 12:35:23 +02:00
Bilal Catic
f258800fd8 change defaultValue for subscribed to false 2019-09-13 12:33:16 +02:00
Bilal Catic
8f09d4f227 change url param case from snake_case to camelCase 2019-09-13 11:11:28 +02:00
Bilal Catic
1a874d4d88 adapt first step of search request to new DB design 2019-09-13 11:08:45 +02:00
Bilal Catic
3cbd79dcc9 create SearchRequest db helper model 2019-09-13 11:06:58 +02:00
Bilal Catic
77d9669ad6 modify URL helper for getting searchRequestId 2019-09-13 11:06:03 +02:00
Bilal Catic
b7d147b0a6 fix SearchRequest model, add default values for not null fields 2019-09-13 10:57:43 +02:00
Bilal Catic
e32e98537c add createdAt, updatedAt fields; add default values for not null fields 2019-09-13 10:56:46 +02:00
Bilal Catic
81ecb37493 add models for new DB design 2019-09-13 09:47:49 +02:00
Bilal Catic
75daf55fdf add migrations for new DB design 2019-09-13 09:46:59 +02:00
Bilal Catic
05f8cbd816 move old models to oldModels folder 2019-09-13 08:57:41 +02:00
Bilal Catic
a61816c5c2 move routes to separated file; stop running crawler in main loop 2019-09-12 06:23:03 +02:00
Bilal Catic
f781dc1cd0 create separate file for routes 2019-09-12 06:21:49 +02:00
Bilal Catic
510c7917dd fix APP_URL variable 2019-09-11 11:27:45 +02:00
Bilal Catic
202c4e441e use process.env.PORT so it can be deployed to Heroku 2019-09-11 11:23:05 +02:00
Bilal Catic
0b78254573 Merge branch 'restrict-email-results-to-10-only' into 'master'
limit number of real estate results in email to 10

See merge request saburly/marketalarm/web!28
2019-09-10 15:25:29 +00:00
Bilal Catic
1f79e1d555 limit number of real estate results in email to 10 2019-09-10 17:16:25 +02:00
Bilal Catic
de3ea71342 Merge branch 'simplify-location-search' into 'master'
Simplify location search

See merge request saburly/marketalarm/web!27
2019-09-10 15:15:09 +00:00
Bilal Catic
74125d761a improve zoom level by incrementing by one 2019-09-10 17:08:24 +02:00
Bilal Catic
9ec7e4be14 add locate me button; fix zoom level 2019-09-10 16:59:59 +02:00
Bilal Catic
aea221f0c3 Change email template; remove region, municipality values 2019-09-10 12:11:44 +02:00
Bilal Catic
4d245f3127 add zoom controls on the location map 2019-09-10 11:49:42 +02:00
Bilal Catic
fbbc0952e3 fix undefined split function exception 2019-09-10 11:49:26 +02:00
Bilal Catic
93fe5ee870 remove region and municipality params when searching olx 2019-09-10 11:19:42 +02:00
Bilal Catic
7fc932350e change realEstateType id property to olxid 2019-09-10 10:58:57 +02:00
Bilal Catic
17a6250b84 change column name for database query 2019-09-10 10:56:16 +02:00
Bilal Catic
78a4fd8eef add placeholder for location input and change map/autocomplete language 2019-09-10 08:04:54 +02:00
Bilal Catic
13c462f328 remove old request parameters and add location parameter to the review 2019-09-10 07:44:43 +02:00
Bilal Catic
af908759bf go to the location view after real estate type selection 2019-09-10 07:43:43 +02:00
Bilal Catic
7a6e1d5cfe add new view and controller for location 2019-09-10 07:43:18 +02:00
Bilal Catic
854984029c remove obsolete controllers and views 2019-09-10 07:42:59 +02:00
Bilal Catic
7a18a89131 return request directly 2019-09-10 05:46:52 +02:00
Bilal Catic
65c9ece073 change bounding box column name from snake case to camel case 2019-09-10 05:45:58 +02:00
Bilal Catic
d529ef64ae add migration 2019-09-09 13:08:32 +02:00
Bilal Catic
f84fed0a01 Merge branch 'project-fixes' into 'master'
Project fixes

See merge request saburly/marketalarm/web!26
2019-09-09 10:49:56 +00:00
Bilal Catic
87b79f3938 fix region and municipality OLX ids 2019-09-07 00:36:47 +02:00
Bilal Catic
68c69144a2 fix scheduler execution rule 2019-09-06 12:25:34 +02:00
Bilal Catic
b0f9c2c47b remove all logging noise 2019-09-06 12:01:25 +02:00
Bilal Catic
1d29f6c8ac change how APP_URL is used, use JS templating string 2019-09-05 14:24:29 +02:00
Bilal Catic
f311404968 allow control over sequelize logging using ENV variable 2019-09-05 13:50:13 +02:00
Bilal Catic
08f73445e9 add ENV variables for DB credentials to override sequelize config 2019-09-05 12:51:01 +02:00
Bilal Catic
60e618fd22 apply prettier on all files 2019-09-05 11:14:54 +02:00
=
719cf4d8f9 Email links now point to our URL and then redirect to the original property URL, ran prettier on the affected files 2019-09-04 07:00:27 -07:00
Nedim Uka
09792db21c Merge branch 'marketalerts-page' into 'master'
Real Estate Page

See merge request saburly/marketalarm/web!24
2019-07-15 09:48:08 +00:00
Nedim Uka
1999d45cb2 Changed template name 2019-07-15 11:47:41 +02:00
Nedim Uka
778b5ff411 Fixed bug, for duplicate results for 2 similar re reqests of one user 2019-07-15 11:40:28 +02:00
Nedim Uka
81c30c36ec Added realestate link to bulk email 2019-07-12 18:00:02 +02:00
Nedim Uka
753a09aa36 Fixed crawler not reading and comparing all RERequest results 2019-07-12 16:13:03 +02:00
Bilal Catic
4517624fa8 Merge branch 'nav-bar' into 'master'
Handle nav-bar

See merge request saburly/marketalarm/web!23
2019-07-12 13:22:16 +00:00
Nedim Uka
f9abf48f61 Removed unecessary comments 2019-07-12 10:53:23 +02:00
Nedim Uka
afeffe8c71 Added roboto font 2019-07-11 14:33:59 +02:00
Nedim Uka
a6bd63b7b8 Handle nav-bar 2019-07-11 14:25:38 +02:00
Bilal Catic
e305c547e1 Merge branch 'fetch-optimisation' into 'master'
Fetch optimization

See merge request saburly/marketalarm/web!22
2019-07-10 14:35:37 +00:00
Nedim Uka
33f9e37d93 Filter data by geolocation now sets hasLocation boolean instead of excluding results 2019-07-10 15:21:46 +02:00
Nedim Uka
5829de64e0 Added hrefs to global varialbe 2019-07-10 12:39:32 +02:00
Bilal Catic
efea857889 Merge branch 'services-scheduler' into 'master'
Added node schedule to run crawler and notification service

See merge request saburly/marketalarm/web!21
2019-07-09 21:51:15 +00:00
Nedim Uka
a43723485c Added node schedule to run crawler and notification service 2019-07-09 16:33:00 +02:00
Nedim Uka
1b098f181c Reduced pager to 5 pages at a time 2019-07-08 13:02:28 +02:00
Bilal Catic
2dd1eaa5fd Merge branch 'crawler-optimisation' into 'master'
Crawler optimisation

See merge request saburly/marketalarm/web!20
2019-07-08 08:01:48 +00:00
Nedim Uka
039b1a6376 Optimiset crawlers , and pagingation 2019-07-05 17:18:47 +02:00
Nedim Uka
222a134bbf Optimised crawler speed by using promises 2019-07-04 17:28:09 +02:00
Nedim Uka
0672f3c019 Changed template name 2019-07-04 09:51:04 +02:00
Bilal Catic
e4b3e3961d Merge branch 'notification-email-subject' into 'master'
Notification email subject

See merge request saburly/marketalarm/web!19
2019-07-04 07:44:49 +00:00
Nedim Uka
a807cb5bf2 Bulk emali subject 2019-07-03 16:01:55 +02:00
Nedim Uka
b79a274f96 Added formated subject to bulk email 2019-07-02 21:49:56 +02:00
Bilal Catic
7f0b2d299e Merge branch 'send-notification' into 'master'
Send notification

See merge request saburly/marketalarm/web!18
2019-07-02 10:32:11 +00:00
Nedim Uka
8b20f0e170 Formated title 2019-07-02 12:25:22 +02:00
Nedim Uka
93c147e73b Looged amazon send bulk email response, fixed some emails not sent bug 2019-07-02 11:54:33 +02:00
Nedim Uka
96e9da1fb1 Send templated bulk email, and remember notifed marketalerts 2019-06-28 18:06:19 +02:00
Nedim Uka
b3baffe174 Send notification email 2019-06-27 17:29:57 +02:00
Nedim Uka
208faa08df Added send notification service, and queried unsent marketalerts, fixed some issues with crawler, and added proper logging 2019-06-25 17:07:02 +02:00
Bilal Catic
5ffdaef1bf Merge branch 'crawler-service' into 'master'
Crawler service

See merge request saburly/marketalarm/web!17
2019-06-24 14:09:47 +00:00
142 changed files with 7126 additions and 3270 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules/
.env
.idea/

View File

@@ -34,3 +34,10 @@ this will create and run postgres image and then execute migrations
5. Run app
`npm start` or `npm run start-mon` to run app with automatic restart on code change
### AWS SES
- AWS SES credentials are handled with env vratiables
- Notification emails are sent in batches of 50, by using SES templates
- Make sure that you are using different templates for different envirorments

183
app/common/enums.js Normal file
View File

@@ -0,0 +1,183 @@
const PRICE_SLIDER_OPTIONS_SALE = {
start: [50000, 85000],
range: {
min: [0],
max: [300000]
},
step: 1000,
connect: true
};
const PRICE_SLIDER_OPTIONS_RENT = {
start: [300, 500],
range: {
min: [0],
max: [2000]
},
step: 50,
connect: true
};
//This will be used for Flats, Apartments, Houses
const HOME_SIZE_SLIDER_OPTIONS = {
start: [30, 75],
range: {
min: [0],
max: [400]
},
step: 5,
connect: true
};
const GARDEN_SIZE_SLIDER_OPTIONS = {
start: [100, 1000],
range: {
min: [0],
max: [10000]
},
step: 100,
connect: true
};
const LAND_SIZE_SLIDER_OPTIONS = {
start: [5000, 15000],
range: {
min: [0],
max: [100000]
},
step: 100,
connect: true
};
const GARAGE_SIZE_SLIDER_OPTIONS = {
start: [10, 20],
range: {
min: [0],
max: [150]
},
step: 2,
connect: true
};
const GARAGE_PRICE_SLIDER_OPTIONS = {
start: [2000, 10000],
range: {
min: [0],
max: [100000]
},
step: 500,
connect: true
};
const AD_TYPE = {
AD_TYPE_SALE: {
id: 1,
stringId: "SALE",
title: "Prodaja"
},
AD_TYPE_RENT: {
id: 2,
stringId: "RENT",
title: "Najam"
},
AD_TYPE_REQUEST: {
id: 3,
stringId: "REQUEST",
title: "Potražnja"
}
};
const AD_CATEGORY = {
ALL: {
id: "ALL"
},
FLAT: {
id: "FLAT",
title: "Stan",
hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
HOUSE: {
id: "HOUSE",
title: "Kuća",
hasGardenSize: true,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
},
OFFICE: {
id: "OFFICE",
title: "Kancelarija",
hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
LAND: {
id: "LAND",
title: "Zemljište",
hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: LAND_SIZE_SLIDER_OPTIONS
},
APARTMENT: {
id: "APARTMENT",
title: "Apartman",
hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
GARAGE: {
id: "GARAGE",
title: "Garaža",
hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: GARAGE_SIZE_SLIDER_OPTIONS
},
COTTAGE: {
id: "COTTAGE",
title: "Vikendica",
hasGardenSize: true,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
}
};
const AD_STATUS = {
STATUS_NORMAL: 1,
STATUS_RESERVED: 2,
STATUS_SOLD: 3,
STATUS_DELETED: 4,
STATUS_URGENT: 5,
STATUS_DISCOUNTED: 6
};
const AD_AGENCY = {
OLX: "OLX",
RENTAL: "RENTAL",
PROSTOR: "PROSTOR",
AKTIDO: "AKTIDO"
};
const CRAWLER_AD_TYPE = {
NONE: 0,
ALL: 1,
ONLY_SELL: 2,
ONLY_RENT: 3,
ONLY_REQUEST: 4
};
module.exports = {
AD_TYPE,
AD_CATEGORY,
AD_STATUS,
AD_AGENCY,
CRAWLER_AD_TYPE
};

43
app/config/appConfig.js Normal file
View File

@@ -0,0 +1,43 @@
"use strict";
require("dotenv").config({ path: __dirname + "/./../../.env" });
const APP_PORT = process.env.PORT || 5000;
const APP_BASE_URL = process.env.APP_BASE_URL || "http://localhost";
const APP_URL =
process.env.NODE_ENV && process.env.NODE_ENV === "production"
? process.env.APP_URL || "http://market-alarm"
: process.env.APP_URL || `${APP_BASE_URL}:${APP_PORT}`;
const DEFAULT_TIMEZONE = "Europe/Sarajevo";
const CRAWLER_INTERVAL = parseInt(process.env.CRAWLER_INTERVAL) || 60;
const STOP_CRAWLER = !!parseInt(process.env.STOP_CRAWLER);
const AWS_EMAIL_CONFIG = {
REGION: process.env.AWS_REGION || "",
CREDENTIALS: {
ACCESS_KEY_ID: process.env.AWS_KEY_ID || "",
SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY || ""
},
SOURCE_EMAIL: process.env.SOURCE_EMAIL || ""
};
const MAX_REAL_ESTATES_IN_EMAIL =
parseInt(process.env.MAX_REAL_ESTATES_IN_EMAIL) || 10;
const MAX_REAL_ESTATES_IN_FIRST_EMAIL =
parseInt(process.env.MAX_REAL_ESTATES_IN_FIRST_EMAIL) || 5;
const PRINT_CRAWLER_DEBUG = process.env.PRINT_CRAWLER_DEBUG_INFO || 0;
module.exports = {
APP_PORT,
APP_URL,
DEFAULT_TIMEZONE,
CRAWLER_INTERVAL,
STOP_CRAWLER,
AWS_EMAIL_CONFIG,
MAX_REAL_ESTATES_IN_EMAIL,
MAX_REAL_ESTATES_IN_FIRST_EMAIL,
PRINT_CRAWLER_DEBUG
};

View File

@@ -1,43 +0,0 @@
const { currentRERequest } = require('../helpers/url');
const { getRealEstateTypeEnum } = require('../helpers/enums');
const getGardenSize = (req,res) => {
const unit = " m2"
const rangeFrom = {
min : 10,
max : 3000,
value : 0,
step : 10
}
const rangeTo = {
min : 10,
max : 3000,
value : 100,
step : 10
}
res.render('gardenSize', { rangeFrom, rangeTo, unit });
};
const postGardenSize = async (req, res) => {
const request = await currentRERequest(req);
const nextStepPage = req.query.nextStep || 'cijena';
const nextStepUrl = `/${nextStepPage}/${request.uniqueId}`;
const realEstateType = getRealEstateTypeEnum(request.realEstateType);
if (realEstateType && realEstateType.hasGardenSize) {
request.gardenSizeMin = req.body.from;
request.gardenSizeMax = req.body.to;
await request.save();
}
res.redirect(nextStepUrl);
};
module.exports = {
getGardenSize,
postGardenSize
};

View File

@@ -1,5 +1,6 @@
const getGoAgain = async (req,res) => {
res.render('goAgain');
const getGoAgain = async (req, res) => {
const title = "Uspjeh!";
res.render("goAgain", { title });
};
module.exports = {

View File

@@ -0,0 +1,57 @@
const { currentSearchRequest } = require("../helpers/url");
const getLocation = async (req, res) => {
const title = "Odaberite lokaciju";
const nextStep = req.query.nextStep || "/";
res.render("location", {
nextStep,
title
});
};
const postLocation = async (req, res) => {
let searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
const northWest = [req.body.west, req.body.north];
const northEast = [req.body.east, req.body.north];
const southEast = [req.body.east, req.body.south];
const southWest = [req.body.west, req.body.south];
const locationInputValue =
req.body.locationInput && req.body.locationInput.length > 0
? req.body.locationInput
: null;
searchRequest.areaToSearch = {
type: "Polygon",
coordinates: [[northWest, northEast, southEast, southWest, northWest]],
crs: { type: "name", properties: { name: "EPSG:4326" } }
};
let locationInputData;
if (req.body.locationInputData) {
try {
locationInputData = JSON.parse(req.body.locationInputData);
} catch (e) {
locationInputData = null;
}
}
await searchRequest.save();
const nextStepPage = req.query.nextStep || "filteri";
const nextStepUrl = `/${nextStepPage}/${searchRequest.id}`;
res.redirect(nextStepUrl);
};
module.exports = {
getLocation,
postLocation
};

View File

@@ -1,26 +0,0 @@
const { currentRERequest } = require('../helpers/url');
const { getMunicipalitiesForRegion, getMunicipalityName } = require('../helpers/codes');
const getMunicipality = async (req, res) => {
let request = await currentRERequest(req);
const municipalities = getMunicipalitiesForRegion(request.region);
res.render('municipality', { municipalities });
};
const postMunicipality = async (req, res) => {
const request = await currentRERequest(req);
const nextStepParam = req.query.nextStep ? "?nextStep=" + req.query.nextStep : "";
const nextStepUrl = `/${'naselje'}/${request.uniqueId}/${getMunicipalityName(request.region, req.body.municipality)}${nextStepParam}`;
request.municipality = req.body.municipality;
await request.save();
res.redirect(nextStepUrl);
};
module.exports = {
getMunicipality,
postMunicipality
};

View File

@@ -1,37 +0,0 @@
const { currentRERequest } = require('../helpers/url');
const getNeighborhood = async (req, res) => {
const municipality = req.params.municipality
const nextStep = req.query.nextStep || '/';
res.render('neighborhoodMap', {
nextStep,
municipality
});
};
const postNeighborhood = async (req, res) => {
let request = await currentRERequest(req);
const northWest = [req.body.west, req.body.north];
const northEast = [req.body.east, req.body.north];
const southEast = [req.body.east, req.body.south];
const southWest = [req.body.west, req.body.south];
request.bounding_box = {
type: 'Polygon', coordinates: [
[northWest, northEast, southEast,
southWest, northWest]
]
};
await request.save();
const nextStepPage = req.query.nextStep || 'povrsina';
const nextStepUrl = `/${nextStepPage}/${request.uniqueId}`;
res.redirect(nextStepUrl);
};
module.exports = {
getNeighborhood,
postNeighborhood
};

View File

@@ -1,40 +0,0 @@
const { currentRERequest } = require('../helpers/url');
const getPrice = (req,res) => {
const unit = " KM"
const rangeFrom = {
min : 1000,
max : 250000,
value : 0,
step : 1000
}
const rangeTo = {
min : 1000,
max : 250000,
value : 50000,
step : 1000
}
res.render('price', {rangeFrom, rangeTo, unit });
};
const postPrice = async (req, res) => {
const request = await currentRERequest(req);
const nextStepPage = req.query.nextStep || 'pregled';
const nextStepUrl = `/${nextStepPage}/${request.uniqueId}`;
request.priceMin = req.body.from;
request.priceMax = req.body.to;
await request.save();
res.redirect(nextStepUrl);
};
module.exports = {
getPrice,
postPrice
};

View File

@@ -1,80 +1,174 @@
const { currentRERequest } = require('../helpers/url');
const { getRegionName, getMunicipalityName } = require('../helpers/codes');
const { realEstateTypes, sizes, gardenSizes, prices, getEnumTypeTitle, getRealEstateTypeEnum } = require('../helpers/enums');
const { currentSearchRequest } = require("../helpers/url");
const { isValidEmail } = require("../helpers/email");
const {
notifyForNewSearchRequest
} = require("../services/notificationService");
const { AD_CATEGORY, AD_TYPE } = require("../common/enums");
const getQueryReview = async (req,res) => {
const request = await currentRERequest(req);
const nextStep = req.query.nextStep;
const getQueryReviewData = searchRequest => {
const {
id,
adType,
realEstateType,
sizeMin,
sizeMax,
gardenSizeMin,
gardenSizeMax,
priceMin,
priceMax
} = searchRequest.dataValues;
if (!request || !request.dataValues) {
return null;
const realEstateTypeObject = AD_CATEGORY[realEstateType];
const enableGardenSizeEdit = realEstateTypeObject
? realEstateTypeObject.hasGardenSize
: false;
let adTypeTitle = "";
switch (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;
}
const {
realEstateType,
region,
municipality,
sizeMin,
sizeMax,
gardenSizeMin,
gardenSizeMax,
priceMin,
priceMax } = request.dataValues;
const realEstateTypeTitle = realEstateTypeObject
? `[${adTypeTitle}] ${realEstateTypeObject.title}`
: "-";
const realEstateTypeObject = getRealEstateTypeEnum(realEstateType);
const enableGardenSizeEdit = realEstateTypeObject ? realEstateTypeObject.hasGardenSize : false;
const locationTitle = "Promjenite lokaciju";
const sizeTitle = `${sizeMin} - ${sizeMax} m2`;
const gardenSizeTitle = enableGardenSizeEdit
? `${gardenSizeMin} - ${gardenSizeMax} m2`
: "-";
const priceTitle = `${priceMin} - ${priceMax} KM`;
const realEstateTypeTitle = realEstateType ? getEnumTypeTitle(realEstateTypes, realEstateType) : null;
const regionName = region ? getRegionName(region) : null;
const municipalityName = (region && municipality) ? getMunicipalityName(region, municipality) : null;
const sizeTitle = sizeMin ? sizeMin + "-" + sizeMax + " m2" : null;
const gardenSizeTitle = gardenSizeMin ? gardenSizeMin + "-" + gardenSizeMax + " m2" : null;
const priceTitle = priceMin ? priceMin + "-" + priceMax + " KM" : null;
const uniqueId = request.dataValues.uniqueId ? request.dataValues.uniqueId : '';
const queryData = [
return [
{
id: 'realEstateType',
id: "realEstateType",
title: realEstateTypeTitle,
url: `/vrstanekretnine/${uniqueId}?nextStep=pregled`,
url: `/vrstanekretnine/${id}?nextStep=filteri`
},
{
id: 'region',
title: regionName,
url: `/grad/${uniqueId}?nextStep=mjesto`,
id: "location",
title: locationTitle,
url: `/lokacija/${id}?nextStep=pregled`
},
{
id: 'municipality',
title: municipalityName,
url: `/mjesto/${uniqueId}?nextStep=pregled`,
},
{
id: 'size',
id: "size",
title: sizeTitle,
url: `/povrsina/${uniqueId}?nextStep=pregled`,
url: `/filteri/${id}?nextStep=pregled`
},
{
id: 'gardenSize',
id: "gardenSize",
title: gardenSizeTitle,
url: enableGardenSizeEdit ? `/okucnica/${uniqueId}?nextStep=pregled` : '',
url: enableGardenSizeEdit ? `/filteri/${id}?nextStep=pregled` : ""
},
{
id: 'price',
id: "price",
title: priceTitle,
url: `/cijena/${uniqueId}?nextStep=pregled`
url: `/filteri/${id}?nextStep=pregled`
}
];
].filter(data => data.title != "-");
};
res.render('queryReview', {
const getQueryReview = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
const title = "Da li je ovo to što ste tražili ?";
const nextStep = req.query.nextStep;
const error = req.query.error;
const queryReviewData = getQueryReviewData(searchRequest);
const email = searchRequest.email;
res.render("queryReview", {
nextStep,
queryData,
queryReviewData,
title,
email,
error
});
};
const postQueryReview = async (req, res) => {
const request = await currentRERequest(req);
const nextStep = req.query.nextStep || `/posalji/${request.uniqueId}`;
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
return null;
}
const nextStep = req.query.nextStep || "/ponovo";
const emailInput = req.body.email;
const emailConfirmInput = req.body.confirmEmail;
const title = "Da li je ovo to što ste tražili ?";
const queryReviewData = getQueryReviewData(searchRequest);
if (emailInput !== emailConfirmInput) {
const error = "Greška ! Unešeni emailovi nisu isti";
res.render("queryReview", {
error,
title,
queryReviewData,
email: ""
});
return;
}
if (!isValidEmail(emailInput)) {
const error = "Greška ! Unesite validan email";
res.render("queryReview", {
error,
title,
queryReviewData,
email: ""
});
return;
}
searchRequest.email = emailInput;
searchRequest.subscribed = true;
try {
await searchRequest.save();
} catch (e) {
console.log("[ERROR] Failed to save search request !", e);
console.log("Search request : ", searchRequest);
const error =
"Greška ! Nismo uspjeli kreirati zahtjev za Vašu pretragu. Molimo pokuštajte ponovo";
res.render("queryReview", {
error,
title,
queryReviewData,
email: ""
});
return;
}
try {
await notifyForNewSearchRequest(searchRequest);
} catch (e) {
console.log("[ERROR] Failed to send initial welcome email", e);
console.log("Search request : ", searchRequest);
const error =
"Greška ! Nismo uspjeli poslati email na Vašu adresu, pokušajte sa drugom email adresom";
res.render("queryReview", {
error,
title,
queryReviewData,
email: ""
});
return;
}
res.redirect(nextStep);
};

View File

@@ -1,51 +0,0 @@
const { currentRERequest } = require('../helpers/url');
const { isValidEmail } = require('../helpers/email');
const { sendTemplatedEmail} = require('../helpers/awsEmail');
const getQuerySubmit = async (req, res) => {
const nextStep = req.query.nextStep;
const error = req.query.error;
res.render('querySubmit', {
nextStep,
error
});
};
const postQuerySubmit = async (req, res) => {
const request = await currentRERequest(req);
const nextStep = req.query.nextStep || '/ponovo';
const emailInput = req.body.email;
const emailConfirmInput = req.body.confirm;
let error = "Greška ! Unesite validan email";
if (!isValidEmail(emailInput) || !isValidEmail(emailConfirmInput)) {
error = "Greška ! Unesite validan email";
res.render('querySubmit', {
error
});
return;
}
if (emailInput !== emailConfirmInput) {
error = "Greška ! Unešeni emailovi nisu isti";
res.render('querySubmit', {
error
});
return;
}
request.email = req.body.email;
request.subscribed = true;
await request.save();
sendTemplatedEmail(req.body.email, request);
res.redirect(nextStep);
};
module.exports = {
getQuerySubmit,
postQuerySubmit
};

View File

@@ -0,0 +1,109 @@
const { currentSearchRequest } = require("../helpers/url");
const { AD_CATEGORY, AD_TYPE } = require("../common/enums");
const getFilters = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
const title = "Filteri za pretraživanje";
const {
adType,
realEstateType,
priceMin,
priceMax,
sizeMin,
sizeMax,
gardenSizeMin,
gardenSizeMax
} = searchRequest;
const category = AD_CATEGORY[realEstateType] || AD_CATEGORY.FLAT;
const {
hasGardenSize,
priceSliderOptionsSale,
priceSliderOptionsRent,
sizeSliderOptions,
gardenSizeSliderOptions
} = category;
let priceSliderOptions;
if (adType === AD_TYPE.AD_TYPE_SALE.stringId) {
priceSliderOptions = Object.assign({}, priceSliderOptionsSale);
} else if (adType === AD_TYPE.AD_TYPE_RENT.stringId) {
priceSliderOptions = Object.assign({}, priceSliderOptionsRent);
} else {
res.render("notFound", { title: " " });
return;
}
if (priceMin || priceMax) {
priceSliderOptions.start = [priceMin, priceMax];
}
if (sizeMin || sizeMax) {
sizeSliderOptions.start = [sizeMin, sizeMax];
}
if (gardenSizeSliderOptions && (gardenSizeMin || gardenSizeMax)) {
gardenSizeSliderOptions.start = [gardenSizeMin, gardenSizeMax];
}
res.render("realEstateFilters", {
title,
hasGardenSize,
priceSliderOptions: JSON.stringify(priceSliderOptions),
sizeSliderOptions: JSON.stringify(sizeSliderOptions),
gardenSizeSliderOptions: JSON.stringify(gardenSizeSliderOptions)
});
};
const postFilters = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
const nextStepPage = req.query.nextStep || "pregled";
const nextStepUrl = `/${nextStepPage}/${searchRequest.id}`;
const priceMin = parseInt(req.body.priceMin) || 0;
const priceMax = parseInt(req.body.priceMax) || 0;
const sizeMin = parseInt(req.body.sizeMin) || 0;
const sizeMax = parseInt(req.body.sizeMax) || 0;
//TODO: Filter validation
searchRequest.priceMin = priceMin;
searchRequest.priceMax = priceMax;
searchRequest.sizeMin = sizeMin;
searchRequest.sizeMax = sizeMax;
if (
req.body.gardenSizeMin !== undefined &&
req.body.gardenSizeMax !== undefined
) {
const gardenSizeMin = parseInt(req.body.gardenSizeMin);
const gardenSizeMax = parseInt(req.body.gardenSizeMax);
//TODO: Filter validation
searchRequest.gardenSizeMin = gardenSizeMin;
searchRequest.gardenSizeMax = gardenSizeMax;
}
await searchRequest.save();
res.redirect(nextStepUrl);
};
module.exports = {
getFilters,
postFilters
};

View File

@@ -1,41 +1,78 @@
const db = require('../models/index');
const { currentSearchRequest } = require("../helpers/url");
const { createSearchRequest } = require("../helpers/db/searchRequest");
const { currentRERequest } = require('../helpers/url');
const { realEstateTypes, getRealEstateTypeEnum } = require('../helpers/enums');
const { AD_CATEGORY, AD_TYPE } = require("../common/enums");
const getRealEstateTypes = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
const getRealEstateTypes = (req,res) => {
res.render('realEstateType', { realEstateTypes });
const title = "Koju nekretninu tražite?";
let selectedAdType = AD_TYPE.AD_TYPE_SALE.id;
if (
searchRequest &&
searchRequest.adType &&
searchRequest.adType === AD_TYPE.AD_TYPE_RENT.stringId
) {
selectedAdType = AD_TYPE.AD_TYPE_RENT.id;
}
const realEstateTypes = Object.keys(AD_CATEGORY)
.map(category => AD_CATEGORY[category])
.filter(category => category.title);
res.render("realEstateType", {
selectedAdType,
realEstateTypes,
title,
AD_TYPE
});
};
const postRealEstateTypes = async (req, res) => {
const request = await currentRERequest(req);
const searchRequest = await currentSearchRequest(req);
const nextStepPage = req.query.nextStep || 'grad';
const adType = parseInt(req.body.adType);
if (request && request.uniqueId) {
const nextStepUrl = `/${nextStepPage}/${request.uniqueId}`;
request.realEstateType = req.body.realestatetype;
if (!getRealEstateTypeEnum(request.realEstateType).hasGardenSize){
request.gardenSize = null;
}
await request.save();
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
};
res.redirect(nextStepUrl)
} else {
db.RealEstateRequest.create({
realEstateType: req.body.realestatetype
}).then( (result) => {
const nextStepUrl = `/${nextStepPage}/${result.uniqueId}`;
res.redirect(nextStepUrl);
}).catch( (e) => {
res.send(e);
});
const adTypeStringId =
adTypeStringIds[adType] || AD_TYPE.AD_TYPE_SALE.stringId;
const validRealEstateTypes = Object.keys(AD_CATEGORY).filter(
category => !!AD_CATEGORY[category].title
);
const selectedRealEstateType = req.body.realEstateType || null;
if (validRealEstateTypes.indexOf(selectedRealEstateType) === -1) {
res.render("notFound", { title: " " });
return;
}
const nextStepPage = req.query.nextStep || "lokacija";
let nextStepUrl = "";
if (searchRequest && searchRequest.id) {
nextStepUrl = `/${nextStepPage}/${searchRequest.id}`;
searchRequest.adType = adTypeStringId;
searchRequest.realEstateType = selectedRealEstateType;
await searchRequest.save();
} else {
try {
const newSearchRequest = await createSearchRequest({
adType: adTypeStringId,
realEstateType: selectedRealEstateType
});
nextStepUrl = `/${nextStepPage}/${newSearchRequest.id}`;
} catch (error) {
console.log(error);
nextStepUrl = `/`;
}
}
res.redirect(nextStepUrl);
};
module.exports = {

View File

@@ -0,0 +1,16 @@
"use strict";
const {
findRealEstatesForSearchRequest
} = require("../helpers/db/searchRequestMatch");
const getRealEstates = async (req, res) => {
const searchRequestId = req.params["searchRequestId"] || "";
const realEstates = await findRealEstatesForSearchRequest(searchRequestId);
const title = "Nekretnine koje odgovaraju Vašim uslovima pretrage";
res.render("realEstates", { realEstates, title });
};
module.exports = {
getRealEstates
};

View File

@@ -0,0 +1,33 @@
const { getRealEstateById } = require("../helpers/db/realEstate");
const getRedirect = async (req, res) => {
const id = req.params.id || null;
let error = false;
let redirectUrl = undefined;
if (!id) {
error = true;
} else {
try {
const realEstate = await getRealEstateById(id);
if (!realEstate) {
error = true;
} else {
redirectUrl = realEstate.url;
}
} catch (e) {
error = true;
}
}
if (error) {
const title = "";
res.render("notFound", { title });
} else {
const title = "Preusmjeravanje";
res.render("redirect", { title, redirectUrl });
}
};
module.exports = {
getRedirect
};

View File

@@ -1,27 +0,0 @@
const { currentRERequest } = require('../helpers/url');
const { getRegions } = require('../helpers/codes');
const regions = getRegions();
const getRegion = (req,res) => {
res.render('region', { regions });
};
const postRegion = async (req, res) => {
const request = await currentRERequest(req);
const nextStepQueryParam = req.query.nextStep ? '?nextStep=pregled' : '';
const nextStepPage = req.query.nextStep || 'mjesto';
const nextStepUrl = `/${nextStepPage}/${request.uniqueId}${nextStepQueryParam}`;
request.region = req.body.region;
request.municipality = null;
await request.save();
res.redirect(nextStepUrl)
};
module.exports = {
getRegion,
postRegion
};

View File

@@ -1,42 +0,0 @@
const { currentRERequest } = require('../helpers/url');
const { sizes, getRealEstateTypeEnum } = require('../helpers/enums');
const getSize = (req,res) => {
const unit = " m2"
const rangeFrom = {
min : 10,
max : 250,
value : 0,
step : 10
}
const rangeTo = {
min : 10,
max : 250,
value : 50,
step : 10
}
res.render('size', { rangeFrom, rangeTo, unit });
};
const postSize = async (req, res) => {
const request = await currentRERequest(req);
const realEstateType = getRealEstateTypeEnum(request.realEstateType);
const nextStep = realEstateType && realEstateType.hasGardenSize ? 'okucnica' : 'cijena';
const nextStepPage = req.query.nextStep || nextStep;
const nextStepUrl = `/${nextStepPage}/${request.uniqueId}`;
request.sizeMin = req.body.from;
request.sizeMax = req.body.to;
await request.save();
res.redirect(nextStepUrl);
};
module.exports = {
getSize,
postSize
};

View File

@@ -1,15 +1,20 @@
const { currentRERequest } = require('../helpers/url');
const { currentSearchRequest } = require("../helpers/url");
const getUnsubscribe = async (req, res) => {
const request = await currentRERequest(req);
request.subscribed = false;
await request.save();
const title = "Uspješno ste se odjavili";
const searchRequest = await currentSearchRequest(req);
res.render('unsubscribe', { nextStep: '/vrstanekretnine' });
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
searchRequest.subscribed = false;
await searchRequest.save();
res.render("unsubscribe", { nextStep: "/vrstanekretnine", title });
};
module.exports = {
getUnsubscribe
};
getUnsubscribe
};

View File

@@ -1,5 +1,5 @@
const getWelcome = (req,res) => {
res.render('welcome', { nextStep: '/vrstanekretnine' } );
const getWelcome = (req, res) => {
res.render("welcome", { nextStep: "/vrstanekretnine", title: false });
};
module.exports = {

81
app/crawler/crawl.js Normal file
View File

@@ -0,0 +1,81 @@
"use strict";
/*
Entry point for crawling functionality
All communication between crawlers and savers is here
All environment specific configuration is read here and
passed to the crawlers and savers.
*/
const OlxCrawler = require("./specificCrawlers/olx");
const RentalCrawler = require("./specificCrawlers/rental");
const ProstorCrawler = require("./specificCrawlers/prostor");
const AktidoCrawler = require("./specificCrawlers/aktido");
const {
OLX_CONFIG,
RENTAL_CONFIG,
PROSTOR_CONFIG,
AKTIDO_CONFIG
} = require("./crawlerConfig");
const PostgresSaver = require("./savers/postgres");
async function crawlAll() {
const postgresSaver = new PostgresSaver();
const crawlers = [
new OlxCrawler(
[postgresSaver],
OLX_CONFIG.OLX_CRAWLER_AD_TYPE,
OLX_CONFIG.OLX_CRAWLER_AD_CATEGORIES,
OLX_CONFIG.OLX_MAX_PAGES,
OLX_CONFIG.OLX_MAX_RESULTS_PER_PAGE,
OLX_CONFIG.OLX_IGNORED_USERNAMES,
OLX_CONFIG.OLX_DELAY_BETWEEN_PAGES
),
new RentalCrawler(
[postgresSaver],
RENTAL_CONFIG.RENTAL_CRAWLER_AD_TYPE,
RENTAL_CONFIG.RENTAL_CRAWLER_AD_CATEGORIES,
RENTAL_CONFIG.RENTAL_MAX_PAGES,
RENTAL_CONFIG.RENTAL_MAX_RESULTS_PER_PAGE,
RENTAL_CONFIG.RENTAL_IGNORED_USERNAMES,
RENTAL_CONFIG.RENTAL_DELAY_BETWEEN_PAGES
),
new ProstorCrawler(
[postgresSaver],
PROSTOR_CONFIG.PROSTOR_CRAWLER_AD_TYPE,
PROSTOR_CONFIG.PROSTOR_CRAWLER_AD_CATEGORIES,
PROSTOR_CONFIG.PROSTOR_MAX_PAGES,
PROSTOR_CONFIG.PROSTOR_MAX_RESULTS_PER_PAGE,
PROSTOR_CONFIG.PROSTOR_IGNORED_USERNAMES,
PROSTOR_CONFIG.PROSTOR_DELAY_BETWEEN_PAGES
),
new AktidoCrawler(
[postgresSaver],
AKTIDO_CONFIG.AKTIDO_CRAWLER_AD_TYPE,
AKTIDO_CONFIG.AKTIDO_CRAWLER_AD_CATEGORIES,
AKTIDO_CONFIG.AKTIDO_MAX_PAGES,
AKTIDO_CONFIG.AKTIDO_MAX_RESULTS_PER_PAGE,
AKTIDO_CONFIG.AKTIDO_IGNORED_USERNAMES,
AKTIDO_CONFIG.AKTIDO_DELAY_BETWEEN_PAGES
)
];
const newRealEstates = [];
for (const crawler of crawlers) {
try {
const newRealEstatesFromSingleCrawler = await crawler.crawl();
if (Array.isArray(newRealEstatesFromSingleCrawler)) {
newRealEstates.push(...newRealEstatesFromSingleCrawler);
}
} catch (e) {
console.log("Error crawling. Trying next crawler! ", e);
}
}
return newRealEstates;
}
module.exports = {
crawlAll
};

View File

@@ -0,0 +1,14 @@
"use strict";
require("dotenv").config({ path: __dirname + "/./../../.env" });
const OLX_CONFIG = require("./specificConfigs/olx");
const RENTAL_CONFIG = require("./specificConfigs/rental");
const PROSTOR_CONFIG = require("./specificConfigs/prostor");
const AKTIDO_CONFIG = require("./specificConfigs/aktido");
module.exports = {
OLX_CONFIG,
RENTAL_CONFIG,
PROSTOR_CONFIG,
AKTIDO_CONFIG
};

5
app/crawler/npmCrawl.js Normal file
View File

@@ -0,0 +1,5 @@
const { crawlAll } = require("./crawl");
(async () => {
await crawlAll();
})();

View File

@@ -0,0 +1,47 @@
const moment = require("moment");
const { bulkUpsertRealEstates } = require("../../helpers/db/realEstate");
class PostgresSaver {
connect() {
//TODO: It seems we never worry about open/close connection with Sequelize ?
//TODO: Check if postgres is ready
return true;
}
async save(results) {
const savedRecords = await bulkUpsertRealEstates(results);
if (Array.isArray(savedRecords)) {
const newRealEstates = [];
const existingRealEstates = [];
for (const savedRecord of savedRecords) {
const { createdAt, updatedAt } = savedRecord;
const createdAtMoment = moment.utc(createdAt);
const updatedAtMoment = moment.utc(updatedAt);
if (createdAtMoment.isSame(updatedAtMoment, "second")) {
newRealEstates.push(savedRecord);
} else {
existingRealEstates.push(savedRecord);
}
}
return {
newRecords: newRealEstates,
existingRecords: existingRealEstates
};
} else {
throw { message: "[POSTGRES] Failed to save records" };
}
}
close() {
//TODO: It seems we never worry about open/close connection with Sequelize ?
return true;
}
}
module.exports = PostgresSaver;

View File

@@ -0,0 +1,34 @@
"use strict";
const { CRAWLER_AD_TYPE, AD_CATEGORY } = require("../../common/enums");
const aktidoCrawlerAdType =
process.env.AKTIDO_CRAWLER_AD_TYPE !== undefined
? CRAWLER_AD_TYPE[process.env.AKTIDO_CRAWLER_AD_TYPE]
: null;
const aktidoParsedCrawlerAdCategories =
process.env.AKTIDO_CRAWLER_AD_CATEGORIES !== undefined
? process.env.AKTIDO_CRAWLER_AD_CATEGORIES.split(",").map(category =>
category.trim()
)
: ["FLAT", "HOUSE"];
const aktidoIgnoredUsernames = [];
const transformedAktidoCrawlerAdCategories = aktidoParsedCrawlerAdCategories
.map(categoryName =>
AD_CATEGORY[categoryName] ? AD_CATEGORY[categoryName].id : undefined
)
.filter(category => !!category);
module.exports = {
AKTIDO_MAX_PAGES: parseInt(process.env.AKTIDO_MAX_PAGES) || 500,
AKTIDO_MAX_RESULTS_PER_PAGE:
parseInt(process.env.AKTIDO_MAX_RESULTS_PER_PAGE) || 50,
AKTIDO_CRAWLER_AD_TYPE: aktidoCrawlerAdType || CRAWLER_AD_TYPE.NONE,
AKTIDO_CRAWLER_AD_CATEGORIES: transformedAktidoCrawlerAdCategories,
AKTIDO_IGNORED_USERNAMES: aktidoIgnoredUsernames || [],
AKTIDO_DELAY_BETWEEN_PAGES:
parseInt(process.env.AKTIDO_DELAY_BETWEEN_PAGES) || 1000,
AKTIDO_FORCE_CRAWL: !!parseInt(process.env.AKTIDO_FORCE_CRAWL)
};

View File

@@ -0,0 +1,39 @@
"use strict";
const { CRAWLER_AD_TYPE, AD_CATEGORY } = require("../../common/enums");
const olxCrawlerAdType =
process.env.OLX_CRAWLER_AD_TYPE !== undefined
? CRAWLER_AD_TYPE[process.env.OLX_CRAWLER_AD_TYPE]
: null;
const olxParsedCrawlerAdCategories =
process.env.OLX_CRAWLER_AD_CATEGORIES !== undefined
? process.env.OLX_CRAWLER_AD_CATEGORIES.split(",").map(category =>
category.trim()
)
: ["FLAT", "HOUSE"];
const olxIgnoredUsernames =
process.env.OLX_IGNORED_USERNAMES !== undefined
? process.env.OLX_IGNORED_USERNAMES.split(",").map(username =>
username.trim()
)
: [];
const transformedOlxCrawlerAdCategories = olxParsedCrawlerAdCategories
.map(categoryName =>
AD_CATEGORY[categoryName] ? AD_CATEGORY[categoryName].id : undefined
)
.filter(category => !!category);
module.exports = {
OLX_MAX_PAGES: parseInt(process.env.OLX_MAX_PAGES) || 500,
OLX_MAX_RESULTS_PER_PAGE:
parseInt(process.env.OLX_MAX_RESULTS_PER_PAGE) || 50,
OLX_CRAWLER_AD_TYPE: olxCrawlerAdType || CRAWLER_AD_TYPE.NONE,
OLX_CRAWLER_AD_CATEGORIES: transformedOlxCrawlerAdCategories,
OLX_IGNORED_USERNAMES: olxIgnoredUsernames || [],
OLX_DELAY_BETWEEN_PAGES:
parseInt(process.env.OLX_DELAY_BETWEEN_PAGES) || 1000,
OLX_FORCE_CRAWL: !!parseInt(process.env.OLX_FORCE_CRAWL)
};

View File

@@ -0,0 +1,33 @@
"use strict";
const { CRAWLER_AD_TYPE, AD_CATEGORY } = require("../../common/enums");
const prostorCrawlerAdType =
process.env.PROSTOR_CRAWLER_AD_TYPE !== undefined
? CRAWLER_AD_TYPE[process.env.PROSTOR_CRAWLER_AD_TYPE]
: null;
const prostorParsedCrawlerAdCategories =
process.env.PROSTOR_CRAWLER_AD_CATEGORIES !== undefined
? process.env.PROSTOR_CRAWLER_AD_CATEGORIES.split(",").map(category =>
category.trim()
)
: ["FLAT", "HOUSE"];
const prostorIgnoredUsernames = [];
const transformedProstorCrawlerAdCategories = prostorParsedCrawlerAdCategories
.map(categoryName =>
AD_CATEGORY[categoryName] ? AD_CATEGORY[categoryName].id : undefined
)
.filter(category => !!category);
module.exports = {
PROSTOR_MAX_PAGES: parseInt(process.env.PROSTOR_MAX_PAGES) || 100,
PROSTOR_MAX_RESULTS_PER_PAGE:
parseInt(process.env.PROSTOR_MAX_RESULTS_PER_PAGE) || 5000,
PROSTOR_CRAWLER_AD_TYPE: prostorCrawlerAdType || CRAWLER_AD_TYPE.NONE,
PROSTOR_CRAWLER_AD_CATEGORIES: transformedProstorCrawlerAdCategories,
PROSTOR_IGNORED_USERNAMES: prostorIgnoredUsernames || [],
PROSTOR_DELAY_BETWEEN_PAGES:
parseInt(process.env.PROSTOR_DELAY_BETWEEN_PAGES) || 1000
};

View File

@@ -0,0 +1,34 @@
"use strict";
const { CRAWLER_AD_TYPE, AD_CATEGORY } = require("../../common/enums");
const rentalCrawlerAdType =
process.env.RENTAL_CRAWLER_AD_TYPE !== undefined
? CRAWLER_AD_TYPE[process.env.RENTAL_CRAWLER_AD_TYPE]
: null;
const rentalParsedCrawlerAdCategories =
process.env.RENTAL_CRAWLER_AD_CATEGORIES !== undefined
? process.env.RENTAL_CRAWLER_AD_CATEGORIES.split(",").map(category =>
category.trim()
)
: ["FLAT", "HOUSE"];
const rentalIgnoredUsernames = [];
const transformedRentalCrawlerAdCategories = rentalParsedCrawlerAdCategories
.map(categoryName =>
AD_CATEGORY[categoryName] ? AD_CATEGORY[categoryName].id : undefined
)
.filter(category => !!category);
module.exports = {
RENTAL_MAX_PAGES: parseInt(process.env.RENTAL_MAX_PAGES) || 500,
RENTAL_MAX_RESULTS_PER_PAGE:
parseInt(process.env.RENTAL_MAX_RESULTS_PER_PAGE) || 50,
RENTAL_CRAWLER_AD_TYPE: rentalCrawlerAdType || CRAWLER_AD_TYPE.NONE,
RENTAL_CRAWLER_AD_CATEGORIES: transformedRentalCrawlerAdCategories,
RENTAL_IGNORED_USERNAMES: rentalIgnoredUsernames || [],
RENTAL_DELAY_BETWEEN_PAGES:
parseInt(process.env.RENTAL_DELAY_BETWEEN_PAGES) || 1000,
RENTAL_FORCE_CRAWL: !!parseInt(process.env.RENTAL_FORCE_CRAWL)
};

View File

@@ -0,0 +1,370 @@
"use strict";
const fetch = require("node-fetch");
const cheerio = require("cheerio");
const Promise = require("bluebird");
const moment = require("moment-timezone");
const htmlToText = require("html-to-text");
const {
AD_TYPE,
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE
} = require("../../common/enums");
const {
DEFAULT_TIMEZONE,
PRINT_CRAWLER_DEBUG
} = require("../../config/appConfig");
const AKTIDO_ENUMS = {
AKTIDO_AD_TYPE: {
[CRAWLER_AD_TYPE.ALL]: "/prodaja-1/najam-2",
[CRAWLER_AD_TYPE.ONLY_SELL]: "/prodaja-1",
[CRAWLER_AD_TYPE.ONLY_RENT]: "/najam-2"
},
AKTIDO_AD_CATEGORY: {
[AD_CATEGORY.ALL.id]: "",
[AD_CATEGORY.FLAT.id]: "/tip-2",
[AD_CATEGORY.HOUSE.id]: "/tip-1",
[AD_CATEGORY.LAND.id]: "/tip-5",
[AD_CATEGORY.OFFICE.id]: "/tip-4",
[AD_CATEGORY.APARTMENT.id]: "/tip-3",
[AD_CATEGORY.GARAGE.id]: "/tip-6"
//[AD_CATEGORY.COTTAGE.id]: ""
},
AKTIDO_PUBLISHED_DATE_FORMAT: "YYYY-MM-DD HH:mm:ss",
AKTIDO_RENEWED_DATE_FORMAT: "YYYY-MM-DD u HH:mm:ss"
};
const { AKTIDO_FORCE_CRAWL } = require("../specificConfigs/aktido");
class AktidoCrawler {
constructor(
savers = [],
crawlerAdTypes = CRAWLER_AD_TYPE.ALL,
crawlerAdCategories = [AD_CATEGORY.FLAT, AD_CATEGORY.HOUSE],
maxPages = 1000,
maxResultsPerPage = 100,
ignoredUsernames = [],
delayBetweenPages = 1000
) {
this.savers = savers;
this.baseUrl = "https://www.aktido.ba/pretraga/sortiraj-date_DESC";
this.crawlerAdTypes = crawlerAdTypes;
this.crawlerAdCategories = crawlerAdCategories;
this.maxPages = maxPages;
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));
}
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 &&
!AKTIDO_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 = AKTIDO_ENUMS.AKTIDO_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = AKTIDO_ENUMS.AKTIDO_AD_CATEGORY[adCategory];
if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) {
while (true) {
const urlPageToCrawl = `${this.baseUrl}${urlAdTypePart}${urlCategoryPart}/stranica-${pageToIndex}`;
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("[AKTIDO] Index page : ", url);
}
try {
const res = await fetch(url);
const body = await res.text();
const $ = cheerio.load(body);
let hrefs = [];
$(
"body > div > div.container > div.row > div.col-xs-12.col-sm-12.col-md-12.col-lg-9.content-main > div.row.box-items.group-grid-view"
)
.find(".moreInfo")
.each((i, elem) => {
const href = $(elem)
.find("a")
.first()
.attr("href");
if (href) {
hrefs.push(href);
}
});
let actualNoOfResults =
hrefs.length <= maxResultsPerPage ? hrefs.length : maxResultsPerPage;
const asyncScraping = [];
for (let i = 0; i < actualNoOfResults; i++) {
asyncScraping.push(this.scrapeAd(hrefs[i]));
}
const scrapedData = await Promise.all(asyncScraping);
const filteredScrapedData = scrapedData.filter(adData => !!adData);
return filteredScrapedData;
} catch (e) {
console.error("[AKTIDO] Exception caught:" + e);
return [];
}
}
async scrapeAd(url) {
// console.log("[AKTIDO] Scraping : ", url);
try {
const adPageSource = await fetch(url);
const body = await adPageSource.text();
const $ = cheerio.load(body);
const mapElementParent = $(".box-map").parent();
const scriptElement = $("script", mapElementParent);
if (
scriptElement[0] &&
scriptElement[0].children &&
scriptElement[0].children[0] &&
scriptElement[0].children[0].data
) {
let extractedData;
try {
//data string starts with : var json_map_data = [{"r ...
//so we remove first 20 characters
const jsonData = scriptElement[0].children[0].data.substring(20);
const parsedJsonData = JSON.parse(jsonData);
extractedData = parsedJsonData[0];
} catch (e) {
throw { message: "Can't find ad data JSON" };
}
const aktidoId = extractedData["re_realEstates_id"];
const adCategory = this.getKiviCategoryIdFromAktidoId(
parseInt(extractedData["re_types_id"])
);
if (!adCategory) {
throw {
message: `Invalid category : ${extractedData["re_types_id"]}`
};
}
const adType = this.getKiviAdTypeFromAktidoActionId(
parseInt(extractedData["re_action_id"])
);
if (!adType) {
throw {
message: `Invalid ad type : ${extractedData["re_action_id"]}`
};
}
const title = extractedData["re_realEstates_portalName"];
const extractedPrice = parseFloat(
extractedData["re_realEstates_price"]
);
const price = extractedPrice ? extractedPrice : null;
const area = parseFloat(extractedData["re_realEstates_area"]);
const gardenSize = parseFloat(
extractedData["re_realEstates_fieldArea"]
);
const longDescription = htmlToText.fromString(
extractedData["re_realEstates_description"]
);
const locationLong = extractedData["re_realEstates_longitude"];
const locationLat = extractedData["re_realEstates_latitude"];
const publishedDateMoment = moment.tz(
extractedData["re_realEstates_inserted"],
AKTIDO_ENUMS.AKTIDO_PUBLISHED_DATE_FORMAT,
DEFAULT_TIMEZONE
);
if (!publishedDateMoment.isValid()) {
throw {
message: `Invalid published date : ${
extractedData["re_realEstates_inserted"]
}`
};
}
const renewedDateMoment = moment.tz(
extractedData["re_realEstates_edited"],
AKTIDO_ENUMS.AKTIDO_RENEWED_DATE_FORMAT,
DEFAULT_TIMEZONE
);
if (!renewedDateMoment.isValid()) {
throw {
message: `Invalid renewed date : ${
extractedData["re_realEstates_edited"]
}`
};
}
const adStatus = AD_STATUS.STATUS_NORMAL;
const data = {
url,
agencyObjectId: aktidoId,
originAgencyName: AD_AGENCY.AKTIDO,
realEstateType: adCategory,
adType,
title,
price,
area,
gardenSize,
shortDescription: "",
longDescription: longDescription,
streetNumber: 0,
streetName: "",
locality: "",
municipality: "",
city: "",
region: "",
entity: "",
country: "",
locationLat,
locationLong,
adStatus,
publishedDate: publishedDateMoment.toISOString(),
renewedDate: renewedDateMoment.toISOString()
};
return data;
} else {
console.log("[AKTIDO] No JSON data for this ad : ", url);
return null;
}
} catch (e) {
console.error("[AKTIDO] Exception caught: " + e.message, "\r\nURL:", url);
return null;
}
return null;
}
//======= HELPER FUNCTIONS =============
getKiviCategoryIdFromAktidoId(aktidoCategoryId) {
switch (aktidoCategoryId) {
case 1:
return AD_CATEGORY.HOUSE.id;
case 2:
return AD_CATEGORY.FLAT.id;
case 3:
return AD_CATEGORY.APARTMENT.id;
case 4:
return AD_CATEGORY.OFFICE.id;
case 5:
return AD_CATEGORY.LAND.id;
case 6:
return AD_CATEGORY.GARAGE.id;
default:
return undefined;
}
}
getKiviAdTypeFromAktidoActionId(actionId) {
switch (actionId) {
case 1:
return AD_TYPE.AD_TYPE_SALE.stringId;
case 2:
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 await savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
}
module.exports = AktidoCrawler;

View File

@@ -0,0 +1,577 @@
"use strict";
const fetch = require("node-fetch");
const cheerio = require("cheerio");
const Promise = require("bluebird");
const moment = require("moment-timezone");
const {
AD_TYPE,
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE
} = require("../../common/enums");
const {
DEFAULT_TIMEZONE,
PRINT_CRAWLER_DEBUG
} = require("../../config/appConfig");
const OLX_ENUMS = {
OLX_AD_TYPE: {
[CRAWLER_AD_TYPE.ALL]: "",
[CRAWLER_AD_TYPE.ONLY_SELL]: "&vrsta=samoprodaja",
[CRAWLER_AD_TYPE.ONLY_RENT]: "&vrsta=samoizdavanje",
[CRAWLER_AD_TYPE.ONLY_REQUEST]: "&vrsta=samopotraznja"
},
OLX_AD_CATEGORY: {
[AD_CATEGORY.FLAT.id]: "&kategorija=23",
[AD_CATEGORY.HOUSE.id]: "&kategorija=24",
[AD_CATEGORY.LAND.id]: "&kategorija=29",
[AD_CATEGORY.OFFICE.id]: "&kategorija=25",
[AD_CATEGORY.APARTMENT.id]: "&kategorija=27",
[AD_CATEGORY.GARAGE.id]: "&kategorija=30",
[AD_CATEGORY.COTTAGE.id]: "&kategorija=26"
},
MAX_DETAIL_FIELDS: 30,
OLX_PUBLISHED_DATE_FORMAT: "DD.MM.YYYY. u HH:mm",
OLX_RENEWED_DATE_FORMAT: "DD.MM.YYYY. u HH:mm"
};
const { OLX_FORCE_CRAWL } = require("../specificConfigs/olx");
class OlxCrawler {
constructor(
savers = [],
crawlerAdTypes = CRAWLER_AD_TYPE.ALL,
crawlerAdCategories = [AD_CATEGORY.FLAT, AD_CATEGORY.HOUSE],
maxPages = 1000,
maxResultsPerPage = 100,
ignoredUsernames = [],
delayBetweenPages = 1000
) {
this.savers = savers;
this.baseUrl = "https://www.olx.ba/pretraga?sort_order=desc&sort_po=datum";
this.crawlerAdTypes = crawlerAdTypes;
this.crawlerAdCategories = crawlerAdCategories;
this.maxPages = maxPages;
this.maxResultsPerPage = maxResultsPerPage;
this.ignoredUsernames = ignoredUsernames;
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));
}
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, existingRecords } = saveResults;
newRealEstates.push(...newRecords);
for (const existingRecord of existingRecords) {
const { publishedDate, renewedDate } = existingRecord;
const publishedDateMoment = moment.utc(publishedDate);
const renewedDateMoment = moment.utc(renewedDate);
const stopCrawlingThisCategory = publishedDateMoment.isSame(
renewedDateMoment,
"minute"
);
if (stopCrawlingThisCategory && !OLX_FORCE_CRAWL) {
generatorsToRemove[index] = true;
// console.log("\tGenerator ", index + 1, "has no more new ads");
break;
}
}
} 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 = OLX_ENUMS.OLX_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = OLX_ENUMS.OLX_AD_CATEGORY[adCategory];
if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) {
while (true) {
const urlPageToCrawl = `${this.baseUrl}${urlAdTypePart}${urlCategoryPart}&stranica=${pageToIndex}`;
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("[OLX] Index page : ", url);
}
try {
const res = await fetch(url);
const body = await res.text();
const $ = cheerio.load(body);
let hrefs = [];
$("#rezultatipretrage")
.find(".listitem")
.each((i, elem) => {
const href = $(elem)
.find("a")
.first()
.attr("href");
if (href) {
hrefs.push(href);
}
});
let actualNoOfResults =
hrefs.length <= maxResultsPerPage ? hrefs.length : maxResultsPerPage;
const asyncScraping = [];
for (let i = 0; i < actualNoOfResults; i++) {
asyncScraping.push(this.scrapeAd(hrefs[i]));
}
const scrapedData = await Promise.all(asyncScraping);
const filteredScrapedData = scrapedData.filter(adData => !!adData);
return filteredScrapedData;
} catch (e) {
console.error("Exception caught:" + e);
return [];
}
}
async scrapeAd(url) {
// console.log("Scraping : ", url);
try {
const adPageSource = await fetch(url);
const body = await adPageSource.text();
const $ = cheerio.load(body);
let status = AD_STATUS.STATUS_NORMAL;
const propertySelectors = {
username:
"#lg > div.desno2.profil > div:nth-child(2) > div.vrsta1.vrsta_desno > a > div.username > span",
title: "#naslovartikla",
descriptions: ".artikal_detaljniopis_tekst",
category:
"#artikal_glavni_div > div.artikal_lijevo > div.artikal_kat > div > span:nth-child(3) > a > span"
};
const username = $(propertySelectors.username)
.text()
.trim();
if (this.ignoredUsernames.includes((username || "").toLowerCase())) {
return null;
}
const title = $(propertySelectors.title)
.text()
.trim();
const descriptions = $(propertySelectors.descriptions);
const category = $(propertySelectors.category)
.text()
.trim();
//====== PRICE DETECTION AND EXTRACTION =====
let price = null;
const normalPriceValue = $("#pc > p:nth-child(2)").text();
const urgentPriceValue = $(
"#artikal_glavni_div > div.artikal_lijevo > div:nth-child(5) > p"
)
.text()
.trim();
if (normalPriceValue && normalPriceValue.length > 0) {
price = normalPriceValue;
if (
$("#pc > p.n")
.text()
.indexOf("Hitna") !== -1
) {
status = AD_STATUS.STATUS_URGENT;
} else {
status = AD_STATUS.STATUS_NORMAL;
}
} else if (urgentPriceValue && urgentPriceValue.length > 0) {
const priceValues = urgentPriceValue.split("KM");
//priceValues will contain values like ["100000", "90000", ...], second element is urgent price
if (priceValues.length > 1) {
price = priceValues[1].trim();
status = AD_STATUS.STATUS_DISCOUNTED;
} else {
throw { message: "Can't find urgent price" };
}
} else {
throw {
message: "Can't find price (it is not normal nor urgent price ?)"
};
}
//====== OTHER AD INFORMATION ===============
let adType = null;
let olxId = null;
let otherInformationDivId;
//We need to locate DIV ID where other information are stored
for (let possibleId = 10; possibleId <= 20; possibleId++) {
const adTypeFieldTitle = $(
`#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${possibleId}) > div:nth-child(2) > div.df1`
)
.text()
.trim();
if (adTypeFieldTitle === "Vrsta oglasa") {
otherInformationDivId = possibleId;
break;
}
}
if (!otherInformationDivId) {
throw { message: "Other information DIV could not be found" };
}
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 renewedDateFullValueSelector = `#artikal_glavni_div > div.artikal_lijevo > div.op.ob.pop`;
const publishedDate = $(publishedDateValueSelector)
.text()
.trim();
const publishedDateMoment = moment.tz(
publishedDate,
OLX_ENUMS.OLX_PUBLISHED_DATE_FORMAT,
DEFAULT_TIMEZONE
);
if (!publishedDateMoment.isValid()) {
throw { message: "Invalid published date ! Check parsing format" };
}
const renewedDate = $(renewedDateFullValueSelector)
.data("content")
.trim();
const renewedDateMoment = moment.tz(
renewedDate,
OLX_ENUMS.OLX_RENEWED_DATE_FORMAT,
DEFAULT_TIMEZONE
);
if (!renewedDateMoment) {
throw {
message:
"Invalid renewed date ! Check how parser parsed renewed date text"
};
}
adType = $(
`#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${otherInformationDivId}) > div:nth-child(2) > div.df2`
)
.text()
.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);
if (!parsedCategory) {
throw { message: `Unknown ad category [${category}]` };
}
const parsedAdType = this.getAdTypeId(adType);
if (!parsedAdType) {
throw { message: "Unknown ad type" };
}
const parsedArea = this.parseArea(area) || null;
const parsedGardenSize = this.parseArea(gardenSize) || null;
const parsedPrice = this.parsePrice(price) || null;
const latLngRegex = /LatLng\(([0-9]+\.[0-9]+)\,\s+([0-9]+\.[0-9]+)\)/g;
const locationLatLngMatches = latLngRegex.exec(body);
let locationLat = null;
let locationLong = null;
if (locationLatLngMatches && locationLatLngMatches.length >= 3) {
locationLat = parseFloat(locationLatLngMatches[1]) || null;
locationLong = parseFloat(locationLatLngMatches[2]) || null;
}
const data = {
url,
agencyObjectId: olxId,
originAgencyName: AD_AGENCY.OLX,
realEstateType: parsedCategory,
adType: parsedAdType,
title,
price: parsedPrice,
area: parsedArea,
gardenSize: parsedGardenSize,
shortDescription: descriptions
.first()
.text()
.trim(),
longDescription: descriptions
.last()
.text()
.trim(),
streetNumber: 0,
streetName: "",
locality: "",
municipality: "",
city: "",
region: "",
entity: "",
country: "",
locationLat,
locationLong,
adStatus: status,
publishedDate: publishedDateMoment.toISOString(),
renewedDate: renewedDateMoment.toISOString()
};
return data;
} catch (e) {
console.error("Exception caught: " + e.message, "\r\nURL:", url);
}
return null;
}
//======= HELPER FUNCTIONS =============
getAdCategoryId(categoryText) {
switch (categoryText) {
case "Stanovi":
return AD_CATEGORY.FLAT.id;
case "Zemljišta":
return AD_CATEGORY.LAND.id;
case "Kuće":
return AD_CATEGORY.HOUSE.id;
case "Poslovni prostori":
return AD_CATEGORY.OFFICE.id;
case "Apartmani":
return AD_CATEGORY.APARTMENT.id;
case "Garaže":
return AD_CATEGORY.GARAGE.id;
case "Vikendice":
return AD_CATEGORY.COTTAGE.id;
default:
return undefined;
}
}
getAdTypeId(adTypeText) {
switch (adTypeText) {
case "Prodaja":
return AD_TYPE.AD_TYPE_SALE.stringId;
case "Izdavanje":
return AD_TYPE.AD_TYPE_RENT.stringId;
case "Potražnja":
return AD_TYPE.AD_TYPE_RENT.stringId;
default:
return undefined;
}
}
parseArea(areaText) {
if (!areaText) {
return NaN;
}
const removeDotsExceptLastOneRegex = /[.](?=.*[.])/g;
const textWithOnlyOneDecimalDot = areaText
.replace(",", ".")
.replace(removeDotsExceptLastOneRegex, "");
return parseFloat(textWithOnlyOneDecimalDot);
}
parsePrice(priceText) {
if (!priceText) {
return NaN;
}
const formattedPriceText = priceText.replace(".", "").replace(",", ".");
return parseFloat(formattedPriceText);
}
parseRenewedDate(renewedDateText) {
const currentMoment = moment.tz(DEFAULT_TIMEZONE);
if (renewedDateText.includes("Prije mjesec dana")) {
return currentMoment.add(-1, "month");
}
if (renewedDateText.includes("Jučer")) {
return currentMoment.add(-1, "day");
}
if (renewedDateText.includes("Prije sat")) {
return currentMoment.add(-1, "hour");
}
if (renewedDateText.includes("dan")) {
// format for this case should be "Prije N dana" or "Prije N dan"
const dateParts = renewedDateText.split(" ");
if (dateParts[0] === "Prije") {
const numberOfDays = parseInt(dateParts[1]);
return currentMoment.add(-1 * numberOfDays, "days");
} else {
return undefined;
}
}
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 renewedDateMoment.isValid() ? renewedDateMoment : 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 await savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
}
module.exports = OlxCrawler;

View File

@@ -0,0 +1,252 @@
"use strict";
const fetch = require("node-fetch");
const cheerio = require("cheerio");
const {
AD_TYPE,
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE
} = require("../../common/enums");
const { PRINT_CRAWLER_DEBUG } = require("../../config/appConfig");
const PROSTOR_ENUMS = {
PROSTOR_AD_TYPE: {
[CRAWLER_AD_TYPE.ALL]: "&action=0",
[CRAWLER_AD_TYPE.ONLY_SELL]: "&action=1",
[CRAWLER_AD_TYPE.ONLY_RENT]: "&action=2"
},
PROSTOR_AD_CATEGORY: {
[AD_CATEGORY.ALL.id]: "",
[AD_CATEGORY.FLAT.id]: "&type=7",
[AD_CATEGORY.HOUSE.id]: "&type=8",
[AD_CATEGORY.LAND.id]: "&type=10",
[AD_CATEGORY.OFFICE.id]: "&type=9",
[AD_CATEGORY.APARTMENT.id]: "&type=11",
[AD_CATEGORY.GARAGE.id]: "&type=14"
//[AD_CATEGORY.COTTAGE.id]: ""
},
PROSTOR_PUBLISHED_DATE_FORMAT: "YYYY-MM-DD HH:mm:ss",
PROSTOR_RENEWED_DATE_FORMAT: "YYYY-MM-DD u HH:mm:ss"
};
class ProstorCrawler {
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://prostor.ba/pretraga";
this.crawlerAdTypes = crawlerAdTypes;
this.crawlerAdCategories = crawlerAdCategories;
this.maxResultsPerPage = maxResultsPerPage;
}
async crawl() {
const crawlAdCategories = this.crawlerAdCategories;
const newRealEstates = [];
if (crawlAdCategories) {
for (const adCategory of crawlAdCategories) {
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=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);
}
}
}
return newRealEstates;
}
async extractRealEstates(url) {
if (PRINT_CRAWLER_DEBUG) {
console.log("[PROSTOR] Index page : ", url);
}
try {
const res = await fetch(url);
const body = await res.text();
const $ = cheerio.load(body);
const scriptElement = $(
"body > div > div.container-fluid > script:nth-child(7)"
);
if (
scriptElement[0] &&
scriptElement[0].children &&
scriptElement[0].children[0] &&
scriptElement[0].children[0].data
) {
const scriptData = scriptElement[0].children[0].data;
try {
// script element data contains JS code and we need to extract only data for realEstates
// data string starts with : var map; var markers = [{"r ...
// so we remove first 23 characters
//
// real estate JSON data ends with ...}, ]; map = new...
// so we need to find index of that substring to know where to stop
// we will NOT include trailing comma because it breaks JSON parse, so we have to close ] bracket manually
const jsonEndIndex = scriptData.indexOf(", ]; map = new");
if (jsonEndIndex > -1) {
const jsonData = scriptData.substring(23, jsonEndIndex) + "]";
const realEstates = JSON.parse(jsonData);
const transformedRealEstates = [];
for (const realEstate of realEstates) {
const transformedRealEstate = ProstorCrawler.transformRealEstateData(
realEstate
);
if (transformedRealEstate) {
transformedRealEstates.push(transformedRealEstate);
}
}
return transformedRealEstates;
} else {
throw {
message: "Something is wrong with JSON data or data is moved"
};
}
} catch (e) {
console.log(e);
throw { message: "Can't find ad data JSON" };
}
}
} 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) {
console.error(
"[PROSTOR] Exception caught: " + e.message,
"\r\nURL:",
url
);
return null;
}
}
//======= HELPER FUNCTIONS =============
static getAdCategoryId(categoryText) {
switch (categoryText) {
case "stan":
return AD_CATEGORY.FLAT.id;
case "kuca":
return AD_CATEGORY.HOUSE.id;
case "apartman":
return AD_CATEGORY.APARTMENT.id;
case "poslovni-prostor":
return AD_CATEGORY.OFFICE.id;
case "garaza":
return AD_CATEGORY.GARAGE.id;
case "zemljiste":
return AD_CATEGORY.LAND.id;
default:
return undefined;
}
}
static 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 saveCrawledResults(results) {
const savers = this.savers;
// for (const saver of savers) {
// await saver.save(results);
// }
//For now, we use only Postgres saver, so ...
return await savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
}
module.exports = ProstorCrawler;

View File

@@ -0,0 +1,370 @@
"use strict";
const fetch = require("node-fetch");
const cheerio = require("cheerio");
const Promise = require("bluebird");
const moment = require("moment-timezone");
const htmlToText = require("html-to-text");
const {
AD_TYPE,
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE
} = require("../../common/enums");
const {
DEFAULT_TIMEZONE,
PRINT_CRAWLER_DEBUG
} = require("../../config/appConfig");
const RENTAL_ENUMS = {
RENTAL_AD_TYPE: {
[CRAWLER_AD_TYPE.ALL]: "/prodaja-1/najam-2",
[CRAWLER_AD_TYPE.ONLY_SELL]: "/prodaja-1",
[CRAWLER_AD_TYPE.ONLY_RENT]: "/najam-2"
},
RENTAL_AD_CATEGORY: {
[AD_CATEGORY.ALL.id]: "",
[AD_CATEGORY.FLAT.id]: "/tip-2",
[AD_CATEGORY.HOUSE.id]: "/tip-1",
[AD_CATEGORY.LAND.id]: "/tip-5",
[AD_CATEGORY.OFFICE.id]: "/tip-4",
[AD_CATEGORY.APARTMENT.id]: "/tip-3",
[AD_CATEGORY.GARAGE.id]: "/tip-6"
//[AD_CATEGORY.COTTAGE.id]: ""
},
RENTAL_PUBLISHED_DATE_FORMAT: "YYYY-MM-DD HH:mm:ss",
RENTAL_RENEWED_DATE_FORMAT: "YYYY-MM-DD u HH:mm:ss"
};
const { RENTAL_FORCE_CRAWL } = require("../specificConfigs/rental");
class RentalCrawler {
constructor(
savers = [],
crawlerAdTypes = CRAWLER_AD_TYPE.ALL,
crawlerAdCategories = [AD_CATEGORY.FLAT, AD_CATEGORY.HOUSE],
maxPages = 1000,
maxResultsPerPage = 100,
ignoredUsernames = [],
delayBetweenPages = 1000
) {
this.savers = savers;
this.baseUrl = "https://www.rental.ba/pretraga/sortiraj-date_DESC";
this.crawlerAdTypes = crawlerAdTypes;
this.crawlerAdCategories = crawlerAdCategories;
this.maxPages = maxPages;
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));
}
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 &&
!RENTAL_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 = RENTAL_ENUMS.RENTAL_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = RENTAL_ENUMS.RENTAL_AD_CATEGORY[adCategory];
if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) {
while (true) {
const urlPageToCrawl = `${this.baseUrl}${urlAdTypePart}${urlCategoryPart}/stranica-${pageToIndex}`;
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("[RENTAL] Index page : ", url);
}
try {
const res = await fetch(url);
const body = await res.text();
const $ = cheerio.load(body);
let hrefs = [];
$(
"body > div > div.container > div.row > div.col-xs-12.col-sm-12.col-md-12.col-lg-9.content-main > div.row.box-items.group-grid-view"
)
.find(".pull-right")
.each((i, elem) => {
const href = $(elem)
.find("a")
.first()
.attr("href");
if (href) {
hrefs.push(href);
}
});
let actualNoOfResults =
hrefs.length <= maxResultsPerPage ? hrefs.length : maxResultsPerPage;
const asyncScraping = [];
for (let i = 0; i < actualNoOfResults; i++) {
asyncScraping.push(this.scrapeAd(hrefs[i]));
}
const scrapedData = await Promise.all(asyncScraping);
const filteredScrapedData = scrapedData.filter(adData => !!adData);
return filteredScrapedData;
} catch (e) {
console.error("[RENTAL] Exception caught:" + e);
return [];
}
}
async scrapeAd(url) {
console.log("[RENTAL] Scraping : ", url);
try {
const adPageSource = await fetch(url);
const body = await adPageSource.text();
const $ = cheerio.load(body);
const mapElementParent = $(".box-map").parent();
const scriptElement = $("script", mapElementParent);
if (
scriptElement[0] &&
scriptElement[0].children &&
scriptElement[0].children[0] &&
scriptElement[0].children[0].data
) {
let extractedData;
try {
//data string starts with : var json_map_data = [{"r ...
//so we remove first 20 characters
const jsonData = scriptElement[0].children[0].data.substring(20);
const parsedJsonData = JSON.parse(jsonData);
extractedData = parsedJsonData[0];
} catch (e) {
throw { message: "Can't find ad data JSON" };
}
const rentalId = extractedData["re_realEstates_id"];
const adCategory = this.getKiviCategoryIdFromRentalId(
parseInt(extractedData["re_types_id"])
);
if (!adCategory) {
throw {
message: `Invalid category : ${extractedData["re_types_id"]}`
};
}
const adType = this.getKiviAdTypeFromRentalActionId(
parseInt(extractedData["re_action_id"])
);
if (!adType) {
throw {
message: `Invalid ad type : ${extractedData["re_action_id"]}`
};
}
const title = extractedData["re_realEstates_portalName"];
const extractedPrice = parseFloat(
extractedData["re_realEstates_price"]
);
const price = extractedPrice ? extractedPrice : null;
const area = parseFloat(extractedData["re_realEstates_area"]);
const gardenSize = parseFloat(
extractedData["re_realEstates_fieldArea"]
);
const longDescription = htmlToText.fromString(
extractedData["re_realEstates_description"]
);
const locationLong = extractedData["re_realEstates_longitude"];
const locationLat = extractedData["re_realEstates_latitude"];
const publishedDateMoment = moment.tz(
extractedData["re_realEstates_inserted"],
RENTAL_ENUMS.RENTAL_PUBLISHED_DATE_FORMAT,
DEFAULT_TIMEZONE
);
if (!publishedDateMoment.isValid()) {
throw {
message: `Invalid published date : ${
extractedData["re_realEstates_inserted"]
}`
};
}
const renewedDateMoment = moment.tz(
extractedData["re_realEstates_edited"],
RENTAL_ENUMS.RENTAL_RENEWED_DATE_FORMAT,
DEFAULT_TIMEZONE
);
if (!renewedDateMoment.isValid()) {
throw {
message: `Invalid renewed date : ${
extractedData["re_realEstates_edited"]
}`
};
}
const adStatus = AD_STATUS.STATUS_NORMAL;
const data = {
url,
agencyObjectId: rentalId,
originAgencyName: AD_AGENCY.RENTAL,
realEstateType: adCategory,
adType,
title,
price,
area,
gardenSize,
shortDescription: "",
longDescription: longDescription,
streetNumber: 0,
streetName: "",
locality: "",
municipality: "",
city: "",
region: "",
entity: "",
country: "",
locationLat,
locationLong,
adStatus,
publishedDate: publishedDateMoment.toISOString(),
renewedDate: renewedDateMoment.toISOString()
};
return data;
} else {
console.log("[RENTAL] No JSON data for this ad : ", url);
return null;
}
} catch (e) {
console.error("[RENTAL] Exception caught: " + e.message, "\r\nURL:", url);
return null;
}
return null;
}
//======= HELPER FUNCTIONS =============
getKiviCategoryIdFromRentalId(rentalCategoryId) {
switch (rentalCategoryId) {
case 1:
return AD_CATEGORY.HOUSE.id;
case 2:
return AD_CATEGORY.FLAT.id;
case 3:
return AD_CATEGORY.APARTMENT.id;
case 4:
return AD_CATEGORY.OFFICE.id;
case 5:
return AD_CATEGORY.LAND.id;
case 6:
return AD_CATEGORY.GARAGE.id;
default:
return undefined;
}
}
getKiviAdTypeFromRentalActionId(actionId) {
switch (actionId) {
case 1:
return AD_TYPE.AD_TYPE_SALE.stringId;
case 2:
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 await savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
}
module.exports = RentalCrawler;

View File

@@ -1,96 +0,0 @@
const dotenv = require('dotenv');
dotenv.config();
const { getRealEstateTypeEnum } = require('./enums');
const { getRegionName, getMunicipalityName } = require('./codes');
const AWS = require('aws-sdk');
AWS.config.update({
region: process.env.AMAZON_REGION,
credentials:
{
accessKeyId: process.env.AMAZON_ACCES_KEY_ID,
secretAccessKey: process.env.AMAZON_SECRET_ACCESS_KEY
}
});
const sendTemplatedEmail = async (email, request) => {
const params = {
Destination: { /* required */
CcAddresses: [
],
ToAddresses: [
email
]
},
Message: { /* required */
Body: { /* required */
Html: {
Charset: "UTF-8",
Data: getEmailHTML(request)
},
Text: {
Charset: "UTF-8",
Data: getEmaiTextVersion(request)
}
},
Subject: {
Charset: 'UTF-8',
Data: `Javimi Potvrda: ${request.realEstateType} ${getRegionName(request.region)}, ${getMunicipalityName(request.region, request.municipality)}`
}
},
Source: process.env.SOURCE_EMAIL, /* required */
ReplyToAddresses: [
process.env.SOURCE_EMAIL,
],
};
const sendEmailPromise = new AWS.SES({ apiVersion: '2010-12-01' }).sendEmail(params).promise();
await sendEmailPromise;
}
const getEmailHTML = (realestateRequest) => {
const realEstateType = getRealEstateTypeEnum(realestateRequest.realEstateType);
const gardenSize = realEstateType.hasGardenSize ? `<div><strong>Kvadratura okućnice: Od ${realestateRequest.gardenSizeMin} do ${realestateRequest.gardenSizeMax} m2 </strong></div>` : ``
return `<h1> Zdravo,
Naručio/la si da ti javimo ako se nekretnina pojavi u oglasima. </h1>
<h2> Ovo je tražena nekretnina: </h2>
<div>
<div> <strong>Tip nekretnine: ${realEstateType.title} </strong></div>
<div><strong>Područje: ${getRegionName(realestateRequest.region)} </strong></div>
<div><strong>Mjesto: ${getMunicipalityName(realestateRequest.region, realestateRequest.municipality)} </strong></div>
<div><strong>Kvadratura nekretnine: Od ${realestateRequest.sizeMin} do ${realestateRequest.sizeMax} m2 </strong></div>
${gardenSize}
<div><strong>Cijena: ${realestateRequest.priceMin} do ${realestateRequest.priceMax} KM </strong></div>
</div>
<div>
</div>
<div><strong> Ako želis prestati dobijati obavještenja za ovu pretragu klikni ${process.env.APP_URL}/odjava/${realestateRequest.uniqueId} </strong></div>
<div><strong>Ako želiš promijeniti uslove pretrage klikni ${process.env.APP_URL}/pregled/${realestateRequest.uniqueId} </strong></div>
<h4> Tvoj,
Javimi tim.
</h4>`
}
const getEmaiTextVersion = (realestateRequest) => {
const realEstateType = getRealEstateTypeEnum(realestateRequest.realEstateType);
const gardenSize = realEstateType.hasGardenSize ? "Kvadratura okućnice od " + realestateRequest.gardenSizeMin + " do " + realestateRequest.gardenSizeMax : ""
const text = "Zdravo, \n Naručio/la si da ti javimo ako se nekretnina pojavi u oglasima \n Ovo je tražena nekretnina: \n , Tip nekretnine: "
+ realestateRequest.realEstateType + "\n Područje" + getRegionName(realestateRequest.region) + "\n Mjesto " + getMunicipalityName(realestateRequest.region, realestateRequest.municipality)
+ "\n Kvadratura nekretnine Od " + realestateRequest.sizeMin + " do " + realestateRequest.sizeMaX +
+ gardenSize
"\n Cijena od " + realestateRequest.priceMin + " do " + realestateRequest.priceMax +
"\n Ako želis prestati dobijati obavještenja za ovu pretragu klikni" + process.env.APP_URL + "/odjava/" + realestateRequest.uniqueId +
"\n Ako želiš promijeniti uslove pretrage klikni " + process.env.APP_URL + "/odpregled/" + realestateRequest.uniqueId +
"\n Tvoj,\n Javimi tim"
return text;
}
module.exports = {
sendTemplatedEmail
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,234 +0,0 @@
const fetch = require('node-fetch');
const cheerio = require('cheerio');
const { allRERequest, findPointInsideBoundingBox } = require('../db/dbHelper');
const { getRealEstateTypeEnum } = require('../enums');
const { getRegion, getMunicipality } = require('../codes')
const Promise = require("bluebird");
module.exports = class OlxCrawler {
//TODO figure best way to handle paging
constructor(fromPage = 0, toPage = 10, maxResults = 1000) {
this.fromPage = fromPage;
this.toPage = toPage;
this.maxResults = maxResults;
}
async indexSingle(url, email) {
try {
const res = await fetch(url);
const body = await res.text();
const $ = cheerio.load(body);
//TODO figure out what to do with username
const username = $('#lg > div.desno2.profil > div:nth-child(2) > div.vrsta1.vrsta_desno > a > div.username > span').text();
// if (IGNORED_USERNAMES.includes((username || '').toLowerCase())) {
// return null;
// }
//TODO remove properties that are not needed, and add some if they are missing
const title = $('#naslovartikla').text();
const realEstateType = $('#artikal_glavni_div > div.artikal_lijevo > div:nth-child(3) > div > span:nth-child(3) > a > span').text();
const price = $('#pc > p:nth-child(2)').text();
const size = $('#dodatnapolja1 > div:nth-child(1) > div.df2').text();
const rooms = $('#dodatnapolja1 > div:nth-child(2) > div.df2').text();
const address = $('#dodatnapolja1 > div:nth-child(5) > div.df2').text();
const gardenSize = $('#dodatnapolja1 > div:nth-child(6) > div.df2').text();
const location = $('#artikal_glavni_div > div.artikal_lijevo > div.op.pop.mobile-lokacija').attr('data-content');
const adType = $('#artikal_glavni_div > div.artikal_lijevo > div:nth-child(15) > div:nth-child(2) > div.df2').text();
const time = $('time').attr('datetime');
const olxId = $('#artikal_glavni_div > div.artikal_lijevo > div:nth-child(15) > div:nth-child(4) > div.df2').text();
const descriptions = $('.artikal_detaljniopis_tekst');
// const floor = $('#dodatnapolja1').find(':contains(Sprat)').last().nextAll().text();
const latLngRe = /LatLng\(([0-9]+\.[0-9]+)\,\s+([0-9]+\.[0-9]+)\)/g;
const imgRe = /href":("[^"]*")/g;
const matches = latLngRe.exec(body);
let lng = '',
lat = '';
const parseRooms = (rooms) => parseInt([...rooms].filter(c => !isNaN(c)).filter(c => c.trim()).join())
const parsePrice = (price) => parseFloat(price.replace(".", ""))
// TODO we dont save images ??
// const images = [];
// const imgMatches = body.match(imgRe);
// for (let i = 0; imgMatches && i < imgMatches.length; i++) {
// let img = imgMatches[i].replace("href\":", "")
// img = img.replace("\"", "");
// img = img.replace("\"", "");
// images.push(img);
// }
// const uploadPromises = images.map(img => {
// const imgFixed = eval(`'${img}'`);
// return cloudinary.uploader.upload(eval(`'${img}'`));
// });
// const uploadResults = await Promise.all(uploadPromises);
// const cloudinaryImages = uploadResults.map(ur => ur.url);
if (matches && matches.length >= 3) {
lat = matches[1];
lng = matches[2];
}
const parsedPrice = parsePrice(price);
const locationArray = location.split(",");
const region = locationArray[0];
const municipality = locationArray[1];
const data = {
realEstateType: this.getCategoryId(realEstateType),
email : email,
olxId: olxId,
// category: category,
url,
title,
price: isNaN(parsedPrice) ? 0 : parsedPrice,
size: parseFloat(size),
gardenSize: isNaN(parseFloat(gardenSize)) ? 0 : parseFloat(gardenSize),
address,
region,
municipality,
// adType: AD_TYPE_SALE,
time,
shortDescription: descriptions.first().text(),
longDescription: descriptions.last().text(),
lat,
lng,
loc: [parseFloat(lat), parseFloat(lng)],
// images: cloudinaryImages
};
return data;
} catch (e) {
console.error('Exception caught: ' + e.message);
}
return null;
}
async indexPage(olxUrl, maxResults = 1000) {
try {
//TODO fix paging
// console.log('Starting to index page: ' + pageNr);
// const url = `http://www.olx.ba/pretraga?vrsta=samoprodaja&sort_order=desc&kategorija=23&sort_po=datum&kanton=9&stranica=${pageNr}`;
const res = await fetch(olxUrl.url);
const body = await res.text();
const $ = cheerio.load(body);
const hrefs = [];
const results = [];
$('#rezultatipretrage').find('.listitem').each((i, elem) => {
const href = $(elem).find('a').first().attr('href');
hrefs.push(href);
});
let actualNoOfResults = (hrefs.length <= maxResults) ? hrefs.length : maxResults;
for (let i = 0; i < hrefs.length; i++) {
console.log(`indexing: ${hrefs[i]}`);
const singleData = await this.indexSingle(hrefs[i], olxUrl.email);
if (singleData) {
results.push(singleData);
}
}
return results;
} catch (e) {
console.error('Exception caught:' + e);
}
}
getCategoryId (category) {
switch(category) {
case 'Stanovi':
return 'stan';
case 'Vikendice':
return 'vikendica'
case 'Kuće':
return 'kuca';
default:
return '';
}
}
async indexPages(urls, start, end, maxResults = 1000) {
//TODO fix paging
// let results = {};
// for (let i = start; i <= end; i++) {
// let result = await this.indexPage(i, maxResults);
// Object.assign(results, result)
// }
// return results;
let results = [];
for (let url of urls) {
let result = await this.indexPage(url, maxResults);
results.push(result);
}
return results;
}
async crawl() {
const filteredResults = [];
const realestateRequests = await allRERequest()
const urls = this.createRequestUrls(realestateRequests);
let results = await this.indexPages(urls, this.fromPage, this.toPage, this.maxResults);
for (const result of results) {
for (const finalResult of result) {
if (finalResult.lat !== undefined && finalResult.lat !== null && finalResult.lat !== "") {
const pointInsideBoundingBox = await findPointInsideBoundingBox([finalResult.lng, finalResult.lat]);
if (pointInsideBoundingBox[0].length !== 0) {
filteredResults.push(finalResult);
}
}
}
}
console.log(filteredResults);
return filteredResults;
}
createRequestUrls(realestateRequests) {
const urls = []
for (const request of realestateRequests) {
const realsestateType = "kategorija=" + getRealEstateTypeEnum(request.realEstateType).olxCategory;
const region = "kanton=" + getRegion(request.region).olxid;
const municipality = "grad%5B%5D=" + getMunicipality(request.region, request.municipality).olxid;
const sizeMin = "kvadrata_min=" + request.sizeMin;
const sizeMax = "kvadrata_max=" + request.sizeMax;
const priceMin = "od=" + request.priceMin;
const priceMax = "do=" + request.priceMax;
const olxUrl = {
url: `https://www.olx.ba/pretraga?${realsestateType}&id=2&stanje=0&vrstapregleda=tabela&sort_order=desc&${region}&${municipality}&${priceMin}&${priceMax}&vrsta=samoprodaja&${sizeMin}&${sizeMax}`,
email: request.email
}
console.log(olxUrl.url);
urls.push(olxUrl);
}
return urls;
}
};

View File

@@ -1,15 +0,0 @@
const db = require('../../models/index');
// TODO Fetch only subscribed realestate requests
const allRERequest = async () => {
return await db.RealEstateRequest.findAll();
}
const findPointInsideBoundingBox = async (latLng) => {
return await db.sequelize.query("SELECT * FROM \"RealEstateRequests\" WHERE ST_Contains(\"RealEstateRequests\".bounding_box, ST_GEOMFROMTEXT(\'POINT (" + latLng[0] + " " + latLng[1]+ ")\'))");
}
module.exports = {
allRERequest,
findPointInsideBoundingBox
};

View File

@@ -0,0 +1,111 @@
"use strict";
const db = require("../../models/index");
const sequelize = require("sequelize");
const Op = sequelize.Op;
const bulkUpsertRealEstates = async realEstateData => {
try {
const fieldsToUpdateIfDuplicate = [
"realEstateType",
"adType",
"price",
"area",
"streetNumber",
"streetName",
"locality",
"municipality",
"city",
"region",
"entity",
"country",
"locationLat",
"locationLong",
"title",
"shortDescription",
"longDescription",
"gardenSize",
"adStatus",
"updatedAt",
"renewedDate"
];
const order = [["updatedAt", "desc"]];
return await db.RealEstate.bulkCreate(realEstateData, {
updateOnDuplicate: fieldsToUpdateIfDuplicate,
returning: true,
order
});
} catch (e) {
console.log("Error bulk upserting realEstates : ", e);
}
};
const getRealEstateById = async id => {
return db.RealEstate.findByPk(id);
};
const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
const {
priceMin,
priceMax,
sizeMin,
sizeMax,
adType,
realEstateType,
areaToSearch
} = searchRequest;
const longitudeColumn = sequelize.col("locationLong");
const latitudeColumn = sequelize.col("locationLat");
const pointGeometry = sequelize.fn(
"ST_Point",
longitudeColumn,
latitudeColumn
);
const pointWithSRID = sequelize.fn("ST_SetSRID", pointGeometry, 4326);
const areaToSearchAsGeometry = sequelize.fn(
"ST_GeomFromGeoJSON",
JSON.stringify(areaToSearch)
);
const areaToSearchWithSRID = sequelize.fn(
"ST_SetSRID",
areaToSearchAsGeometry,
4326
);
const contains = sequelize.fn(
"ST_Contains",
areaToSearchWithSRID,
pointWithSRID
);
const geoSearchQueryPart = sequelize.where(contains, true);
const query = {
adType,
realEstateType,
price: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
area: {
[Op.lte]: sizeMax,
[Op.gte]: sizeMin
},
[Op.and]: geoSearchQueryPart
};
const order = [["updatedAt", "desc"]];
return await db.RealEstate.findAll({
where: query,
limit: maxResults,
order
});
};
module.exports = {
bulkUpsertRealEstates,
getRealEstateById,
findRealEstatesForSearchRequest
};

View File

@@ -0,0 +1,74 @@
"use strict";
const db = require("../../models/index");
const sequelize = require("sequelize");
const Op = sequelize.Op;
const getSearchRequest = async searchRequestId => {
try {
return await db.SearchRequest.findByPk(searchRequestId);
} catch (error) {
return null;
}
};
const createSearchRequest = async (searchRequestFields = {}) => {
return await db.SearchRequest.create(searchRequestFields);
};
const findSearchRequestsForRealEstate = async realEstate => {
const {
price,
area,
adType,
realEstateType,
locationLat,
locationLong
} = realEstate;
if (!locationLat || !locationLong) {
return [];
}
const stGeometry = sequelize.fn(
"ST_GEOMFROMTEXT",
`POINT (${locationLong} ${locationLat})`,
4326
);
const areaToSearchColumn = sequelize.col("areaToSearch");
const contains = sequelize.fn("ST_Contains", areaToSearchColumn, stGeometry);
const geoSearchQueryPart = sequelize.where(contains, true);
const query = {
adType,
realEstateType,
subscribed: true,
[Op.and]: geoSearchQueryPart
};
if (price) {
query.priceMin = {
[Op.lte]: price
};
query.priceMax = {
[Op.gte]: price
};
}
if (area) {
query.sizeMin = {
[Op.lte]: area
};
query.sizeMax = {
[Op.gte]: area
};
}
return await db.SearchRequest.findAll({ where: query });
};
module.exports = {
getSearchRequest,
createSearchRequest,
findSearchRequestsForRealEstate
};

View File

@@ -0,0 +1,36 @@
"use strict";
const db = require("../../models/index");
const findRealEstatesForSearchRequest = async searchRequestId => {
const query = {
searchRequestId
};
const realEstatesModel = { model: db.RealEstate, as: "realEstates" };
const order = [[realEstatesModel, "updatedAt", "desc"]];
const include = [realEstatesModel];
const matches = await db.SearchRequestMatch.findAll({
where: query,
include,
order
});
const matchingRealEstates = [];
for (const match of matches) {
matchingRealEstates.push(...match.realEstates);
}
return matchingRealEstates;
};
const addMatches = async matchingRecords => {
return await db.SearchRequestMatch.bulkCreate(matchingRecords, {
ignoreDuplicates: true
});
};
module.exports = {
findRealEstatesForSearchRequest,
addMatches
};

View File

@@ -1,7 +1,6 @@
const isValidEmail = (email) => {
const simpleEmailRegex = /^.+@.+\..+$/;
return (email && email.length < 250 && simpleEmailRegex.test(email));
const isValidEmail = email => {
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,}))$/;
return email && email.length < 250 && simpleEmailRegex.test(email);
};
module.exports = {

View File

@@ -0,0 +1,112 @@
"use strict";
const { MAX_REAL_ESTATES_IN_EMAIL, APP_URL } = require("../config/appConfig");
const { AD_CATEGORY } = require("../common/enums");
const generateEmailFooter = searchRequestId => {
return `<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>
<br/>
<strong>Vaš,<br/>Kivi tim</strong>`;
};
const generateRealEstateLinks = realEstates => {
let realEstateLinks = "";
for (const realEstate of realEstates) {
const { id: realEstateId, title } = realEstate;
realEstateLinks += `<li><a href="${APP_URL}/redirect/${realEstateId}">${title}</a></li>`;
}
return realEstateLinks;
};
const generateNotificationEmail = (realEstates, searchRequestId) => {
const truncateList = realEstates.length > MAX_REAL_ESTATES_IN_EMAIL;
const realEstatesToShow = truncateList
? realEstates.slice(0, MAX_REAL_ESTATES_IN_EMAIL)
: realEstates;
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>
<h4>Pronašli smo nekretnine koje odgovaraju Vašoj pretrazi</h4>
<div>
${realEstateLinks}
<div/>
${moreRealEstates}
</div>
<br/>
${emailFooter}`;
};
const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
const realEstateType = AD_CATEGORY[searchRequest.realEstateType];
const {
id,
gardenSizeMin,
gardenSizeMax,
sizeMin,
sizeMax,
priceMin,
priceMax
} = searchRequest;
const realEstateLinks = generateRealEstateLinks(matchingRealEstates);
const instantRealEstatesText = `<br/>
<div>
U međuvremenu pogledajte neke od nedavno objavljenih nekretnina koje odgovaraju Vašim uslovima pretrage :<br/>
${realEstateLinks}
</div>`;
const gardenSize = realEstateType.hasGardenSize
? `<div><strong>Kvadratura okućnice: Od ${gardenSizeMin} do ${gardenSizeMax} m2</strong></div>`
: ``;
const emailFooter = generateEmailFooter(id);
return `<h3>Zdravo</h3>
<div>Naručili ste da Vam javimo ako se nekretnina sa navedenim uslovima pojavi u oglasima:</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>
${matchingRealEstates.length > 0 ? instantRealEstatesText : ""}
<br/>
${emailFooter}`;
};
const generateEmailSubject = (numberOfRealEstates, singleRealEstateTitle) => {
if (numberOfRealEstates === 1) {
return `Kivi: ${singleRealEstateTitle}`;
}
const leastSignificantDigit = numberOfRealEstates % 10;
const numberWithoutLastDigit = Math.floor(numberOfRealEstates / 10);
const secondLeastSignificantDigit = numberWithoutLastDigit % 10;
if (leastSignificantDigit === 1 && secondLeastSignificantDigit !== 1) {
return `Kivi : ${numberOfRealEstates} nova nekretnina`;
}
if (
leastSignificantDigit >= 2 &&
leastSignificantDigit <= 4 &&
secondLeastSignificantDigit !== 1
) {
return `Kivi: ${numberOfRealEstates} nove nekretnine`;
}
return `Kivi: ${numberOfRealEstates} novih nekretnina`;
};
module.exports = {
generateNotificationEmail,
generateNewSearchRequestEmail,
generateEmailSubject
};

View File

@@ -1,57 +0,0 @@
const realEstateTypes = [
{ title: "Kuća", id: "kuca", hasGardenSize: true, olxCategory: 24 },
{ title: "Stan", id: "stan", hasGardenSize: false, olxCategory: 23},
{ title: "Vikendica", id: "vikendica", hasGardenSize: true, olxCategory: 26 }
];
const sizes = [
{ title: "do 50 m2", id: "50m2" },
{ title: "do 75 m2", id: "75m2" },
{ title: "do 100 m2", id: "100m2" },
{ title: "do 150 m2", id: "150m2" },
{ title: "do 200 m2", id: "200m2" },
{ title: "preko 200 m2", id: "moreThan200m2" }
];
const gardenSizes = [
{ title: "do 100 m2", id: "100m2" },
{ title: "do 500 m2", id: "500m2" },
{ title: "do 1 dunum", id: "1000m2" },
{ title: "do 2 dunuma", id: "2000m2" },
{ title: "do 3 dunuma", id: "3000m2" },
{ title: "preko 3 dunuma", id: "moreThan3000m2" }
];
const prices = [
{ title: "do 50 000 KM", id: "50kKM" },
{ title: "do 100 000 KM", id: "100kKM" },
{ title: "do 150 000 KM", id: "150kKM" },
{ title: "do 200 000 KM", id: "200kKM" },
{ title: "do 250 000 KM", id: "250kKM" },
{ title: "preko 250 000 KM", id: "moreThan250kKM" }
];
const getEnumObject = (enumType, enumId) => {
return enumType.find(enumValue => enumValue.id === enumId);
};
const getRealEstateTypeEnum = (enumId) => {
return getEnumObject(realEstateTypes, enumId) || null;
}
const getEnumTypeTitle = (enumType, enumId) => {
const enumObject = getEnumObject(enumType, enumId);
if (!enumObject){
return null;
}
return enumObject.title;
};
module.exports = {
realEstateTypes,
sizes,
gardenSizes,
prices,
getRealEstateTypeEnum,
getEnumTypeTitle,
};

25
app/helpers/forceSSL.js Normal file
View File

@@ -0,0 +1,25 @@
/**
* Force load with https on production environment
* https://devcenter.heroku.com/articles/http-routing#heroku-headers
*/
module.exports = function(environments, status) {
environments = environments || ["production"];
status = status || 301;
// console.log("New force SSL ");
// console.log("\tenvs : ", environments);
// console.log("\tstatus: ", status);
// console.log("\tENV : ", process.env.NODE_ENV);
return function(req, res, next) {
if (environments.indexOf(process.env.NODE_ENV) >= 0) {
if (req.headers["x-forwarded-proto"] !== "https") {
const urlToRedirectTo = `https://${req.hostname}${req.originalUrl}`;
// console.log("\tRedirect :", urlToRedirectTo);
res.redirect(status, urlToRedirectTo);
} else {
next();
}
} else {
next();
}
};
};

View File

@@ -1,23 +1,12 @@
const db = require('../models/index');
const { getSearchRequest } = require("./db/searchRequest");
const currentRERequest = async (req) => {
const uniqueId = req.params['request_id'];
if(!uniqueId) return null;
const currentSearchRequest = async req => {
const searchRequestId =
req && req.params ? req.params["searchRequestId"] : null;
if (!searchRequestId) return null;
const request = await db.RealEstateRequest.findOne({ where: {uniqueId} });
return request;
return await getSearchRequest(searchRequestId);
};
// TODO Fetch only subscribed realestate requests
const allRERequest = async () => {
return await db.RealEstateRequest.findAll();
}
const findPointInsideBoundingBox = async (latLng) => {
return await db.sequelize.query("SELECT * FROM \"RealEstateRequests\" WHERE ST_Contains(\"RealEstateRequests\".bounding_box, ST_GEOMFROMTEXT(\'POINT (" + latLng[0] + " " + latLng[1]+ ")\'))");
}
module.exports = {
currentRERequest,
allRERequest,
findPointInsideBoundingBox
currentSearchRequest
};

View File

@@ -1,10 +0,0 @@
const convertToDate = require("./convertToDate");
function areThereAnyNewItems(lastItemDate, controlDate) {
if (!lastItemDate) {
return true;
}
return new Date(controlDate) < convertToDate(lastItemDate);
}
module.exports = areThereAnyNewItems;

View File

@@ -1,13 +0,0 @@
function convertToDate(date) {
const [dan, mjesec, godina] = date
.split(". u ")[0]
.split(".")
.map(el => Number(el));
const [sati, minute] = date
.split(". u ")[1]
.split(":")
.map(el => Number(el));
return new Date(godina, mjesec, dan, sati, minute);
}
module.exports = convertToDate;

View File

@@ -1,42 +0,0 @@
let fetch = require("node-fetch");
let cheerio = require("cheerio");
const areThereAnyNewItems = require("./areThereAnyNewItems");
async function scrapTheItems(url, controlDate, noNewItems = false) {
let items = [];
let response = await fetch(url);
const body = await response.text();
const $ = cheerio.load(body);
$("#rezultatipretrage")
.find(".listitem")
.each(async (index, elem) => {
if (noNewItems) return;
const itemDate = $(elem)
.find(".cijena > .datum > div")
.first()
.attr("data-cijelidatum");
if (controlDate && !areThereAnyNewItems(itemDate, controlDate)) {
noNewItems = true;
return;
}
const id = $(elem)
.find("a")
.first()
.attr("href");
const cijena = $(elem)
.find(".cijena > .datum > span")
.first()
.text();
const image = $(elem)
.find("a > .slika > img")
.first()
.attr("src");
items.push({ url: id, price: cijena, image, date: itemDate });
});
return items;
}
module.exports = scrapTheItems;

View File

@@ -1,58 +0,0 @@
const scrapTheItems = require("./scrapTheItems");
const convertToDate = require("./convertToDate");
const AWS = require('aws-sdk');
// AWS.config.update({region: 'eu-central-1'});
async function sendNotification(marketAlert) {
const { id, email, olx_url } = marketAlert;
let url =
"https://www.olx.ba/pretraga?" + olx_url + "&sort_order=desc&sort_po=datum";
let newItems = await scrapTheItems(url);
let lastDate = newItems.length && newItems[0].date;
let message =
newItems.length &&
newItems.reduce(
(mes, item) => mes + `<strong>${item.url} i ${item.price}</strong>`,
""
);
// Create sendEmail params
const params = {
Destination: { /* required */
CcAddresses: [
],
ToAddresses: [
email
]
},
Message: { /* required */
Body: { /* required */
Html: {
Charset: "UTF-8",
Data: message
},
Text: {
Charset: "UTF-8",
Data: message // TODO: convert to text
}
},
Subject: {
Charset: 'UTF-8',
Data: 'Javimi alert'
}
},
Source: 'info@saburly.com', /* required */
ReplyToAddresses: [
'info@saburly.com',
],
};
if (message) {
const sendPromise = new AWS.SES({apiVersion: '2010-12-01'}).sendEmail(params).promise();
await sendPromise;
return { id, date: String(convertToDate(lastDate)) };
}
}
module.exports = sendNotification;

View File

@@ -1,7 +1,7 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('MarketAlerts', {
return queryInterface.createTable("MarketAlerts", {
id: {
allowNull: false,
autoIncrement: true,
@@ -29,6 +29,6 @@ module.exports = {
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('MarketAlerts');
return queryInterface.dropTable("MarketAlerts");
}
};

View File

@@ -1,7 +1,7 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('RealEstateRequests', {
return queryInterface.createTable("RealEstateRequests", {
id: {
allowNull: false,
autoIncrement: true,
@@ -28,6 +28,6 @@ module.exports = {
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('RealEstateRequests');
return queryInterface.dropTable("RealEstateRequests");
}
};

View File

@@ -1,18 +1,15 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn(
'RealEstateRequests',
'city',
Sequelize.STRING
"RealEstateRequests",
"city",
Sequelize.STRING
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn(
'RealEstateRequests',
'city'
);
return queryInterface.removeColumn("RealEstateRequests", "city");
}
};

View File

@@ -1,18 +1,15 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn(
'RealEstateRequests',
'place',
Sequelize.STRING
"RealEstateRequests",
"place",
Sequelize.STRING
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn(
'RealEstateRequests',
'place'
);
return queryInterface.removeColumn("RealEstateRequests", "place");
}
};

View File

@@ -1,19 +1,19 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
'RealEstateRequests',
'place',
'municipality'
"RealEstateRequests",
"place",
"municipality"
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
'RealEstateRequests',
'municipality',
'place'
"RealEstateRequests",
"municipality",
"place"
);
}
};

View File

@@ -1,19 +1,11 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
'RealEstateRequests',
'city',
'region'
);
return queryInterface.renameColumn("RealEstateRequests", "city", "region");
},
down: (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
'RealEstateRequests',
'region',
'city'
);
return queryInterface.renameColumn("RealEstateRequests", "region", "city");
}
};

View File

@@ -1,20 +1,13 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn(
'RealEstateRequests',
'size',
{
type: Sequelize.STRING
}
);
return queryInterface.addColumn("RealEstateRequests", "size", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn(
'RealEstateRequests',
'size'
);
return queryInterface.removeColumn("RealEstateRequests", "size");
}
};

View File

@@ -1,20 +1,13 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn(
'RealEstateRequests',
'gardenSize',
{
type: Sequelize.STRING
}
);
return queryInterface.addColumn("RealEstateRequests", "gardenSize", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn(
'RealEstateRequests',
'gardenSize'
);
return queryInterface.removeColumn("RealEstateRequests", "gardenSize");
}
};

View File

@@ -1,20 +1,13 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn(
'RealEstateRequests',
'price',
{
type: Sequelize.STRING
}
);
return queryInterface.addColumn("RealEstateRequests", "price", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn(
'RealEstateRequests',
'price'
);
return queryInterface.removeColumn("RealEstateRequests", "price");
}
};

View File

@@ -1,15 +1,19 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.query("CREATE EXTENSION postgis").then(([results, metadata]) => {
/// No result
})
return queryInterface.sequelize
.query("CREATE EXTENSION postgis")
.then(([results, metadata]) => {
/// No result
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.query("DROP EXTENSION IF EXISTS postgis").then(([results, metadata]) => {
return queryInterface.sequelize
.query("DROP EXTENSION IF EXISTS postgis")
.then(([results, metadata]) => {
/// No result
})
});
}
};

View File

@@ -1,17 +1,21 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.query("ALTER TABLE \"RealEstateRequests\" ADD COLUMN bounding_box geometry(Polygon);").then(([results, metadata]) => {
/// No result
})
return queryInterface.sequelize
.query(
'ALTER TABLE "RealEstateRequests" ADD COLUMN bounding_box geometry(Polygon);'
)
.then(([results, metadata]) => {
/// No result
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.query("ALTER TABLE \"RealEstateRequests\" DROP COLUMN bounding_box").then(([results, metadata]) => {
/// No result
})
return queryInterface.sequelize
.query('ALTER TABLE "RealEstateRequests" DROP COLUMN bounding_box')
.then(([results, metadata]) => {
/// No result
});
}
};
};

View File

@@ -1,27 +1,48 @@
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction((t) => {
return Promise.all([
queryInterface.addColumn('RealEstateRequests', 'sizeRange', {
type: Sequelize.STRING
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'gardenSizeRange', {
type: Sequelize.STRING,
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'priceRange', {
type: Sequelize.STRING,
}, { transaction: t })
])
})
},
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.addColumn(
"RealEstateRequests",
"sizeRange",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"gardenSizeRange",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"priceRange",
{
type: Sequelize.STRING
},
{ transaction: t }
)
]);
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction((t) => {
return Promise.all([
queryInterface.removeColumn('RealEstateRequests', 'sizeRange', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'gardenSizeRange', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'priceRange', { transaction: t })
])
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("RealEstateRequests", "sizeRange", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "gardenSizeRange", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "priceRange", {
transaction: t
})
}
};
]);
});
}
};

View File

@@ -1,63 +1,147 @@
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction((t) => {
return Promise.all([
queryInterface.removeColumn('RealEstateRequests', 'sizeRange', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'gardenSizeRange', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'priceRange', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'size', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'gardenSize', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'price', { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'gardenSizeMin', {
type: Sequelize.INTEGER,
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'gardenSizeMax', {
type: Sequelize.INTEGER,
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'sizeMin', {
type: Sequelize.INTEGER
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'sizeMax', {
type: Sequelize.INTEGER,
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'priceMin', {
type: Sequelize.INTEGER,
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'priceMax', {
type: Sequelize.INTEGER
}, { transaction: t })
])
})
},
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("RealEstateRequests", "sizeRange", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "gardenSizeRange", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "priceRange", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "size", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "gardenSize", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "price", {
transaction: t
}),
queryInterface.addColumn(
"RealEstateRequests",
"gardenSizeMin",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"gardenSizeMax",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"sizeMin",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"sizeMax",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"priceMin",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"priceMax",
{
type: Sequelize.INTEGER
},
{ transaction: t }
)
]);
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction((t) => {
return Promise.all([
queryInterface.removeColumn('RealEstateRequests', 'gardenSizeMin', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'gardenSizeMax', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'sizeMin', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'sizeMax', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'priceMin', { transaction: t }),
queryInterface.removeColumn('RealEstateRequests', 'priceMin', { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'priceMax', {
type: Sequelize.STRING
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'gardenSizeRange', {
type: Sequelize.STRING,
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'priceRange', {
type: Sequelize.STRING,
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'size', {
type: Sequelize.STRING
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'gardenSize', {
type: Sequelize.STRING,
}, { transaction: t }),
queryInterface.addColumn('RealEstateRequests', 'price', {
type: Sequelize.STRING,
}, { transaction: t })
])
})
}
};
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("RealEstateRequests", "gardenSizeMin", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "gardenSizeMax", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "sizeMin", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "sizeMax", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "priceMin", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "priceMin", {
transaction: t
}),
queryInterface.addColumn(
"RealEstateRequests",
"priceMax",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"gardenSizeRange",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"priceRange",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"size",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"gardenSize",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"price",
{
type: Sequelize.STRING
},
{ transaction: t }
)
]);
});
}
};

View File

@@ -1,18 +1,15 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn(
'RealEstateRequests',
'subscribed',
Sequelize.BOOLEAN
"RealEstateRequests",
"subscribed",
Sequelize.BOOLEAN
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn(
'RealEstateRequests',
'subscribed'
);
return queryInterface.removeColumn("RealEstateRequests", "subscribed");
}
};

View File

@@ -1,37 +1,70 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction((t) => {
return Promise.all([
queryInterface.addColumn('MarketAlerts', 'size', {
type: Sequelize.INTEGER,
}, { transaction: t }),
queryInterface.addColumn('MarketAlerts', 'gardenSize', {
type: Sequelize.INTEGER,
}, { transaction: t }),
queryInterface.addColumn('MarketAlerts', 'price', {
type: Sequelize.INTEGER,
}, { transaction: t }),
queryInterface.addColumn('MarketAlerts', 'municipality', {
type: Sequelize.STRING,
}, { transaction: t }),
queryInterface.addColumn('MarketAlerts', 'region', {
type: Sequelize.STRING,
}, { transaction: t })
])
})
},
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.addColumn(
"MarketAlerts",
"size",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"gardenSize",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"price",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"municipality",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"region",
{
type: Sequelize.STRING
},
{ transaction: t }
)
]);
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction((t) => {
return Promise.all([
queryInterface.removeColumn('MarketAlerts', 'size', { transaction: t }),
queryInterface.removeColumn('MarketAlerts', 'gardenSize', { transaction: t }),
queryInterface.removeColumn('MarketAlerts', 'price', { transaction: t }),
queryInterface.removeColumn('MarketAlerts', 'municipality', { transaction: t }),
queryInterface.removeColumn('MarketAlerts', 'region', { transaction: t })
])
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("MarketAlerts", "size", { transaction: t }),
queryInterface.removeColumn("MarketAlerts", "gardenSize", {
transaction: t
}),
queryInterface.removeColumn("MarketAlerts", "price", {
transaction: t
}),
queryInterface.removeColumn("MarketAlerts", "municipality", {
transaction: t
}),
queryInterface.removeColumn("MarketAlerts", "region", {
transaction: t
})
}
]);
});
}
};

View File

@@ -1,33 +1,59 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction((t) => {
return Promise.all([
queryInterface.removeColumn('MarketAlerts', 'olxUrl', { transaction: t }),
queryInterface.addColumn('MarketAlerts', 'url', {
type: Sequelize.STRING,
}, { transaction: t }),
queryInterface.addColumn('MarketAlerts', 'realestateOrigin', {
type: Sequelize.STRING,
}, { transaction: t }),
queryInterface.addColumn('MarketAlerts', 'originId', {
type: Sequelize.STRING,
}, { transaction: t })
])
})
},
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("MarketAlerts", "olxUrl", {
transaction: t
}),
queryInterface.addColumn(
"MarketAlerts",
"url",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"realestateOrigin",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"originId",
{
type: Sequelize.STRING
},
{ transaction: t }
)
]);
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction((t) => {
return Promise.all([
queryInterface.removeColumn('MarketAlerts', 'url', { transaction: t }),
queryInterface.removeColumn('MarketAlerts', 'realestateOrigin', { transaction: t }),
queryInterface.removeColumn('MarketAlerts', 'originId', { transaction: t }),
queryInterface.addColumn('MarketAlerts', 'olxUrl', {
type: Sequelize.STRING
}, { transaction: t })
])
})
}
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("MarketAlerts", "url", { transaction: t }),
queryInterface.removeColumn("MarketAlerts", "realestateOrigin", {
transaction: t
}),
queryInterface.removeColumn("MarketAlerts", "originId", {
transaction: t
}),
queryInterface.addColumn(
"MarketAlerts",
"olxUrl",
{
type: Sequelize.STRING
},
{ transaction: t }
)
]);
});
}
};

View File

@@ -1,20 +1,13 @@
'use strict';
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn(
'MarketAlerts',
'realEstateType',
{
type: Sequelize.STRING
}
);
return queryInterface.addColumn("MarketAlerts", "realEstateType", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn(
'MarketAlerts',
'realEstateType'
);
return queryInterface.removeColumn("MarketAlerts", "realEstateType");
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
"RealEstateRequests",
"bounding_box",
"boundingBox"
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
"RealEstateRequests",
"boundingBox",
"bounding_box"
);
}
};

View File

@@ -0,0 +1,72 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
const tableFields = {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
allowNull: false,
primaryKey: true
},
url: {
type: Sequelize.TEXT,
allowNull: false
},
agencyObjectId: {
type: Sequelize.TEXT,
allowNull: false
},
originAgencyName: {
type: Sequelize.TEXT,
allowNull: false
},
realEstateType: {
type: Sequelize.TEXT,
allowNull: false
},
adType: {
type: Sequelize.TEXT,
allowNull: false
},
price: Sequelize.REAL,
area: Sequelize.REAL,
gardenSize: Sequelize.REAL,
streetNumber: Sequelize.INTEGER,
streetName: Sequelize.TEXT,
locality: Sequelize.TEXT,
municipality: Sequelize.TEXT,
city: Sequelize.TEXT,
region: Sequelize.TEXT,
entity: Sequelize.TEXT,
country: Sequelize.TEXT,
locationLat: Sequelize.REAL,
locationLong: Sequelize.REAL,
lastTimeCrawled: {
type: Sequelize.DATE,
allowNull: false
},
deleted: {
type: Sequelize.BOOLEAN,
allowNull: false
},
sold: {
type: Sequelize.BOOLEAN,
allowNull: false
},
createdAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
},
updatedAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
}
};
return queryInterface.createTable("RealEstates", tableFields);
},
down: queryInterface => {
return queryInterface.dropTable("RealEstates", {});
}
};

View File

@@ -0,0 +1,79 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
const tableFields = {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true
},
areaToSearch: {
type: Sequelize.GEOMETRY("POLYGON", 4326),
allowNull: false,
defaultValue: {
type: "Polygon",
coordinates: [[[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]],
crs: { type: "name", properties: { name: "EPSG:4326" } }
}
},
realEstateType: {
type: Sequelize.TEXT,
allowNull: false
},
adType: {
type: Sequelize.TEXT,
allowNull: false,
defaultValue: "sell"
},
email: Sequelize.TEXT,
locality: Sequelize.TEXT,
municipality: Sequelize.TEXT,
city: Sequelize.TEXT,
region: Sequelize.TEXT,
entity: Sequelize.TEXT,
country: Sequelize.TEXT,
sizeMin: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
sizeMax: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
priceMin: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
priceMax: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
gardenSizeMin: Sequelize.INTEGER,
gardenSizeMax: Sequelize.INTEGER,
subscribed: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
},
createdAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
},
updatedAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
}
};
return queryInterface.createTable("SearchRequests", tableFields);
},
down: queryInterface => {
return queryInterface.dropTable("SearchRequests", {});
}
};

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("RealEstates", "shortDescription", {
type: Sequelize.STRING
}),
queryInterface.addColumn("RealEstates", "longDescription", {
type: Sequelize.STRING
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("RealEstates", "shortDescription"),
queryInterface.removeColumn("RealEstates", "longDescription")
]);
}
};

View File

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

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addConstraint(
"RealEstates",
["originAgencyName", "agencyObjectId"],
{
type: "unique",
name: "agencyNameObjectIdUniqueKey"
}
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeConstraint(
"RealEstates",
"agencyNameObjectIdUniqueKey"
);
}
};

View File

@@ -0,0 +1,14 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstates", "lastTimeCrawled");
},
down: (queryInterface, Sequelize) => {
return queryInterface.addColumn("RealEstates", "lastTimeCrawled", {
type: Sequelize.DATE,
notNull: true
});
}
};

View File

@@ -0,0 +1,23 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("RealEstates", "deleted"),
queryInterface.removeColumn("RealEstates", "sold")
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("RealEstates", "deleted", {
type: Sequelize.BOOLEAN,
notNull: true
}),
queryInterface.addColumn("RealEstates", "sold", {
type: Sequelize.BOOLEAN,
notNull: true
})
]);
}
};

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.changeColumn("RealEstates", "shortDescription", {
type: Sequelize.TEXT
}),
queryInterface.changeColumn("RealEstates", "longDescription", {
type: Sequelize.TEXT
}),
queryInterface.changeColumn("RealEstates", "title", {
type: Sequelize.TEXT
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([]);
}
};

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("RealEstates", "publishedDate", {
type: Sequelize.DATE
}),
queryInterface.addColumn("RealEstates", "renewedDate", {
type: Sequelize.DATE
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("RealEstates", "renewedDate"),
queryInterface.removeColumn("RealEstates", "publishedDate")
]);
}
};

View File

@@ -0,0 +1,15 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "adType" = 'SALE' WHERE "adType" = 'sell';`
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "adType" = 'sell' WHERE "adType" = 'SALE';`
);
}
};

View File

@@ -0,0 +1,31 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'HOUSE' WHERE "realEstateType" = 'kuca';`
),
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'FLAT' WHERE "realEstateType" = 'stan';`
),
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'COTTAGE' WHERE "realEstateType" = 'vikendica';`
)
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'kuca' WHERE "realEstateType" = 'HOUSE';`
),
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'stan' WHERE "realEstateType" = 'FLAT';`
),
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'vikendica' WHERE "realEstateType" = 'COTTAGE';`
)
]);
}
};

View File

@@ -1,27 +1,39 @@
'use strict';
"use strict";
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const fs = require("fs");
const path = require("path");
const Sequelize = require("sequelize");
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.json')[env];
const env = process.env.NODE_ENV || "development";
const config = require(__dirname + "/../config/config.json")[env];
const db = {};
config.username = process.env.DB_USERNAME || config.username;
config.password = process.env.DB_PASSWORD || config.password;
config.database = process.env.DB_NAME || config.database;
config.port = process.env.DB_PORT || config.port;
config.logging = parseInt(process.env.SEQUELIZE_LOGGING) ? console.log : false;
let sequelize;
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(config.database, config.username, config.password, config);
sequelize = new Sequelize(
config.database,
config.username,
config.password,
config
);
}
fs
.readdirSync(__dirname)
fs.readdirSync(__dirname)
.filter(file => {
return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
return (
file.indexOf(".") !== 0 && file !== basename && file.slice(-3) === ".js"
);
})
.forEach(file => {
const model = sequelize['import'](path.join(__dirname, file));
const model = sequelize["import"](path.join(__dirname, file));
db[model.name] = model;
});

View File

@@ -1,24 +0,0 @@
'use strict';
module.exports = (sequelize, DataTypes) => {
const MarketAlert = sequelize.define('MarketAlert', {
url: DataTypes.STRING,
realestateOrigin: DataTypes.STRING,
originId: DataTypes.STRING,
lastDate: DataTypes.STRING,
size : DataTypes.INTEGER,
gardenSize : DataTypes.INTEGER,
price : DataTypes.INTEGER,
municipality : DataTypes.STRING,
region : DataTypes.STRING,
realEstateType : DataTypes.STRING,
email: {
type: DataTypes.STRING,
allowNul: false
}
}, {});
MarketAlert.associate = function(models) {
// associations can be defined here
};
return MarketAlert;
};

55
app/models/realEstate.js Normal file
View File

@@ -0,0 +1,55 @@
"use strict";
module.exports = (sequelize, DataTypes) => {
const RealEstate = sequelize.define("RealEstate", {
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
allowNull: false,
primaryKey: true
},
url: {
type: DataTypes.TEXT,
allowNull: false
},
originAgencyName: {
type: DataTypes.TEXT,
allowNull: false,
unique: true
},
agencyObjectId: {
type: DataTypes.TEXT,
allowNull: false,
unique: true
},
realEstateType: {
type: DataTypes.TEXT,
allowNull: false
},
adType: {
type: DataTypes.TEXT,
allowNull: false
},
price: DataTypes.REAL,
area: DataTypes.REAL,
gardenSize: DataTypes.REAL,
streetNumber: DataTypes.INTEGER,
streetName: DataTypes.TEXT,
locality: DataTypes.TEXT,
municipality: DataTypes.TEXT,
city: DataTypes.TEXT,
region: DataTypes.TEXT,
entity: DataTypes.TEXT,
country: DataTypes.TEXT,
locationLat: DataTypes.REAL,
locationLong: DataTypes.REAL,
title: DataTypes.TEXT,
shortDescription: DataTypes.TEXT,
longDescription: DataTypes.TEXT,
adStatus: DataTypes.INTEGER,
publishedDate: DataTypes.DATE,
renewedDate: DataTypes.DATE
});
return RealEstate;
};

View File

@@ -1,28 +0,0 @@
'use strict';
module.exports = (sequelize, DataTypes) => {
const RealEstateRequest = sequelize.define('RealEstateRequest', {
uniqueId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
allowNull: false
},
realEstateType: DataTypes.STRING,
email: DataTypes.STRING,
region: DataTypes.STRING,
municipality: DataTypes.STRING,
sizeMin: DataTypes.INTEGER,
sizeMax: DataTypes.INTEGER,
gardenSizeMin: DataTypes.INTEGER,
gardenSizeMax: DataTypes.INTEGER,
priceMin: DataTypes.INTEGER,
priceMax: DataTypes.INTEGER,
bounding_box: DataTypes.GEOMETRY('POINT', 4326),
subscribed: DataTypes.BOOLEAN
}, {});
RealEstateRequest.associate = function(models) {
// associations can be defined here
};
return RealEstateRequest;
};

View File

@@ -0,0 +1,68 @@
"use strict";
const { AD_TYPE } = require("../common/enums");
module.exports = (sequelize, DataTypes) => {
const SearchRequest = sequelize.define("SearchRequest", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
allowNull: false,
primaryKey: true
},
areaToSearch: {
type: DataTypes.GEOMETRY("POLYGON", 4326),
allowNull: false,
defaultValue: {
type: "Polygon",
coordinates: [[[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]],
crs: { type: "name", properties: { name: "EPSG:4326" } }
}
},
realEstateType: {
type: DataTypes.TEXT,
allowNull: false
},
adType: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: AD_TYPE.AD_TYPE_SALE.stringId
},
email: DataTypes.TEXT,
locality: DataTypes.TEXT,
municipality: DataTypes.TEXT,
city: DataTypes.TEXT,
region: DataTypes.TEXT,
entity: DataTypes.TEXT,
country: DataTypes.TEXT,
sizeMin: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
sizeMax: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
priceMin: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
priceMax: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
gardenSizeMin: DataTypes.INTEGER,
gardenSizeMax: DataTypes.INTEGER,
subscribed: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false
}
});
return SearchRequest;
};

View File

@@ -0,0 +1,54 @@
"use strict";
module.exports = (sequelize, DataTypes) => {
const SearchRequestMatch = sequelize.define(
"SearchRequestMatch",
{
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
allowNull: false
},
realEstateId: {
type: DataTypes.BIGINT,
allowNull: false,
primaryKey: true,
references: {
model: "RealEstate",
key: "id"
},
onUpdate: "CASCADE",
onDelete: "SET NULL"
},
searchRequestId: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true,
references: {
model: "SearchRequest",
key: "id"
}
},
notified: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
},
{
name: {
singular: "searchRequestMatch",
plural: "searchRequestMatches"
}
}
);
SearchRequestMatch.associate = models => {
SearchRequestMatch.hasMany(models.RealEstate, {
foreignKey: "id",
as: "realEstates"
});
};
return SearchRequestMatch;
};

View File

@@ -0,0 +1,32 @@
"use strict";
module.exports = (sequelize, DataTypes) => {
const MarketAlert = sequelize.define(
"MarketAlert",
{
url: DataTypes.STRING,
realestateOrigin: DataTypes.STRING,
originId: DataTypes.STRING,
lastDate: DataTypes.STRING,
size: DataTypes.INTEGER,
gardenSize: DataTypes.INTEGER,
price: DataTypes.INTEGER,
municipality: DataTypes.STRING,
region: DataTypes.STRING,
realEstateType: DataTypes.STRING,
notified: DataTypes.BOOLEAN,
title: DataTypes.STRING,
request: DataTypes.STRING,
hasLocation: DataTypes.BOOLEAN,
email: {
type: DataTypes.STRING,
allowNul: false
}
},
{}
);
MarketAlert.associate = function(models) {
// associations can be defined here
};
return MarketAlert;
};

View File

@@ -0,0 +1,32 @@
"use strict";
module.exports = (sequelize, DataTypes) => {
const RealEstateRequest = sequelize.define(
"RealEstateRequest",
{
uniqueId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
allowNull: false
},
realEstateType: DataTypes.STRING,
email: DataTypes.STRING,
region: DataTypes.STRING,
municipality: DataTypes.STRING,
sizeMin: DataTypes.INTEGER,
sizeMax: DataTypes.INTEGER,
gardenSizeMin: DataTypes.INTEGER,
gardenSizeMax: DataTypes.INTEGER,
priceMin: DataTypes.INTEGER,
priceMax: DataTypes.INTEGER,
boundingBox: DataTypes.GEOMETRY("POINT", 4326),
subscribed: DataTypes.BOOLEAN,
locationInput: DataTypes.STRING
},
{}
);
RealEstateRequest.associate = function(models) {
// associations can be defined here
};
return RealEstateRequest;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More