Compare commits

...

328 Commits

Author SHA1 Message Date
Bilal Catic
5bc0d4f8c2 Merge branch 'implement-renting-option-frontend' into 'master'
Implement renting option frontend

See merge request saburly/marketalarm/web!65
2019-10-31 18:12:42 +00:00
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
Nedim Uka
1aa91fb4e2 Fixed gardenSize 2019-06-24 15:34:59 +02:00
Nedim Uka
2cf6f6f1ff Code refactoring, fixed bug with price parsing: 2019-06-24 14:20:31 +02:00
Nedim Uka
6eba5c2a97 gardenSize nan 2019-06-24 11:49:13 +02:00
Nedim Uka
2f474619ca Compare crawler results with db, and only save new if necessary 2019-06-21 16:48:19 +02:00
Nedim Uka
80ff9bcb6b saving additional fields, improved async functions with promises 2019-06-21 15:14:43 +02:00
Nedim Uka
3c59292f23 refactoring 2019-06-20 21:27:51 +02:00
Nedim Uka
1bcc5e8e5d Preparing to save results to db 2019-06-20 14:51:14 +02:00
Nedim Uka
c8ee848f0e Improved results filtering by lat lng 2019-06-20 10:57:37 +02:00
Nedim Uka
0f630e9ea4 Olix crawling, filter crawling result by lat, lng 2019-06-19 17:12:22 +02:00
Nedim Uka
9a8a27d1d9 Scheduler 2019-06-18 15:05:40 +02:00
Nedim Uka
b17b6862ba Added migrations, expanded maketalert table 2019-06-18 14:01:09 +02:00
Nedim Uka
6aaaea1612 working on crawler 2019-06-13 15:49:31 +02:00
Nedim Uka
fdd0124924 Added crawler service 2019-06-13 13:31:35 +02:00
Nedim Uka
c15f45e8f4 Fixed map not loding bug 2019-06-12 15:20:58 +02:00
Nedim Uka
371eac900e Merge branch 'range-slider' into 'master'
Range slider

See merge request saburly/marketalarm/web!16
2019-06-12 11:37:15 +00:00
Nedim Uka
5d6e7f3938 fixed slider css overlaping 2019-06-12 13:36:49 +02:00
Nedim Uka
efda7fdccd Added nouiRange slider 2019-06-12 13:32:28 +02:00
Nedim Uka
8bb0908c45 Slider thumb fix 2019-06-11 16:22:17 +02:00
Bilal Catic
5c75d690b0 Merge branch 'slider-bug' into 'master'
Sliders bug

See merge request saburly/marketalarm/web!15
2019-06-11 11:21:33 +00:00
Nedim Uka
f0e8a72756 Sliders now returning to correct range if they go beyond allowed value 2019-06-11 11:44:59 +02:00
Bilal Catic
62bf3380cd Merge branch 'confirmation-email' into 'master'
Geocoding restricttions, added values for range finders, added confirmation email, and .env file

See merge request saburly/marketalarm/web!14
2019-06-11 08:42:06 +00:00
Nedim Uka
caa1871939 deleted env file 2019-06-11 10:34:48 +02:00
Nedim Uka
506ac67956 Fixed garden size email issues 2019-06-11 10:26:48 +02:00
Nedim Uka
8f9e3ae46a Geocoding restricttions, added values for range finders, added confirmation email, and .env file 2019-06-10 17:29:31 +02:00
Bilal Catic
d6e999fcf1 Merge branch 'double-email' into 'master'
Double email

See merge request saburly/marketalarm/web!13
2019-05-30 12:08:25 +00:00
Nedim Uka
08a94ca4f8 Fixed google maps bug, changed size, gardenSize, and price colum names, fixed bug with query review not showing default values 2019-05-30 10:43:47 +02:00
Nedim Uka
a0f2b044b2 Added validation to email confirmation 2019-05-29 17:10:41 +02:00
Nedim Uka
7db74acad7 Set range fileds to be integer instead of strings 2019-05-29 17:10:41 +02:00
Bilal Catic
56865b4670 Merge branch 'realestate-size-slider' into 'master'
Real Estate Slider

See merge request saburly/marketalarm/web!12
2019-05-29 10:18:17 +00:00
Nedim Uka
1a8ac3fba4 Added range slider to gardensize and price 2019-05-29 11:03:01 +02:00
Nedim Uka
de3c76315e Real Estate Slider 2019-05-28 16:46:38 +02:00
Bilal Catic
e969a8dc8b Merge branch 'edit-bug-fix' into 'master'
Fixed bug related to map region edit

See merge request saburly/marketalarm/web!11
2019-05-27 15:52:11 +00:00
Nedim Uka
f4baec23cf Fixed bug related to map region edit 2019-05-27 15:17:41 +02:00
Bilal Catic
5bf95e0594 Merge branch 'google-maps' into 'master'
Added google maps step

See merge request saburly/marketalarm/web!10
2019-05-27 08:35:04 +00:00
Nedim Uka
6fbacb326f Fixed bounding box bug, and removed unecesary params 2019-05-27 09:18:54 +02:00
Nedim Uka
be416ffc0c Added google maps step 2019-05-24 16:16:47 +02:00
Nedim Uka
a3d9a82fee Merge branch 'fix-minor-bugs' into 'master'
Fix minor bugs

See merge request saburly/marketalarm/web!9
2019-05-24 13:54:40 +00:00
Nedim Uka
6772f8a953 Merge branch 'enable-return-to-query-review-directly' into 'master'
Enable return to query review directly

See merge request saburly/marketalarm/web!8
2019-05-24 13:53:27 +00:00
Nedim Uka
89a3c9e355 Merge branch 'change-migrations-use-string-instead-of-enums' into 'master'
Change migrations - use string instead of enum

See merge request saburly/marketalarm/web!7
2019-05-24 13:50:55 +00:00
Bilal Catic
dd38602c5a add simple email validation 2019-05-22 16:57:08 +02:00
Bilal Catic
a3f76d20fe fix URL on send icon 2019-05-22 15:58:42 +02:00
Bilal Catic
fc1275566e handle undefined realEstateType 2019-05-22 13:19:27 +02:00
Bilal Catic
c64ee42914 Skip and prevent saving garden size if not needed 2019-05-22 11:36:01 +02:00
Bilal Catic
aa3c965d5c skip to query review directly when editing data 2019-05-21 15:32:47 +02:00
Bilal Catic
126da48852 modify realEstateRequest model to use String instead of enum 2019-05-21 15:26:53 +02:00
Bilal Catic
58ae430564 modify migrations - use string instead of enum 2019-05-21 15:26:53 +02:00
Senad Uka
315a29749c Update database configuration 2019-05-21 12:11:30 +02:00
Nedim Uka
02bee9cf2c Merge branch 'add-steps-to-wizard' into 'master'
Add steps to wizard

See merge request saburly/marketalarm/web!6
2019-05-21 09:39:43 +00:00
Bilal Catic
1c2847509a add final page 2019-05-19 19:45:19 +02:00
Bilal Catic
87dc742e41 add query submit page 2019-05-19 13:34:44 +02:00
Bilal Catic
70ddc1f734 add query review page 2019-05-19 12:29:55 +02:00
Bilal Catic
2c415bbd79 use enums from enum file 2019-05-19 10:03:52 +02:00
Bilal Catic
b07eb5bbeb fix available value input for size 2019-05-19 10:03:36 +02:00
Bilal Catic
53585d3ae1 use enums from enum file 2019-05-19 02:14:20 +02:00
Bilal Catic
c652a306db add price screen 2019-05-17 11:32:41 +02:00
Bilal Catic
b15295bfe6 add garden size screen 2019-05-17 11:12:24 +02:00
Bilal Catic
7ad1117cae add size screen 2019-05-17 11:06:32 +02:00
Nedim Uka
393f6731e6 Merge branch 'refactor' into 'master'
Refactor

See merge request saburly/marketalarm/web!5
2019-05-17 07:46:44 +00:00
Bilal Catic
93faa7c9e3 update readme 2019-05-17 09:14:16 +02:00
Bilal Catic
93f5d8071e add npm commands for docker and setup 2019-05-17 09:10:03 +02:00
Bilal Catic
7192c28c07 update readme 2019-05-17 08:55:36 +02:00
Bilal Catic
76f9457d4f add nodemon and migrate scripts 2019-05-17 08:49:01 +02:00
Bilal Catic
68172951ed change region and municipality property names to english 2019-05-17 00:52:43 +02:00
Bilal Catic
dbf40b199e Merge branch 'refactor' of https://gitlab.com/saburly/marketalarm/web into refactor 2019-05-17 00:34:13 +02:00
Bilal Catic
4309bc709d change column name from 'city' to 'region' 2019-05-17 00:33:10 +02:00
Bilal Catic
4323017d02 fix Readme 2019-05-16 21:34:32 +00:00
Bilal Catic
42505a7089 change column name from 'place' to 'municipality' 2019-05-16 23:32:18 +02:00
Bilal Catic
1542310a81 clean code 2019-05-16 19:58:48 +02:00
Bilal Catic
c505062770 change controller file name to plural 2019-05-16 19:42:15 +02:00
Bilal Catic
ab681e5eeb change file names to CamelCase 2019-05-16 19:40:26 +02:00
Bilal Catic
616eddbb19 improve Readme 2019-05-16 17:12:17 +02:00
Bilal Catic
e5eb6b99a2 Merge branch 'rename' into 'master'
Refactoring

See merge request saburly/marketalarm/web!4
2019-05-16 12:49:52 +00:00
Nedim Uka
27fa721627 Renaming to english 2019-05-16 13:00:08 +02:00
Senad Uka
9fdfce49ed Merge branch 'dockerize-database' into 'master'
Dockerize database

See merge request saburly/marketalarm/web!2
2019-04-30 12:07:09 +00:00
Senad Uka
864b917b4f Make place selection possible 2019-04-30 06:48:41 +02:00
Senad Uka
a2f6f033bf Places almost finished 2019-04-28 11:13:46 +02:00
Senad Uka
64f2cb82a8 Scrape kantoni into html file 2019-04-28 09:02:46 +02:00
131 changed files with 9723 additions and 679 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,48 +1,11 @@
#
# example Dockerfile for https://docs.docker.com/engine/examples/postgresql_service/
#
FROM postgres:11.3
FROM ubuntu:16.04
ENV POSTGIS_MAJOR 2.4
# Add the PostgreSQL PGP key to verify their Debian packages.
# It should be the same key as https://www.postgresql.org/media/keys/ACCC4CF8.asc
RUN apt-key adv --keyserver hkp://p80.pool.sks-keyservers.net:80 --recv-keys B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8
RUN apt-get update \
&& apt-get --assume-yes install software-properties-common postgis\
&& rm -rf /var/lib/apt/lists/
# Add PostgreSQL's repository. It contains the most recent stable release
# of PostgreSQL, ``9.3``.
RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ precise-pgdg main" > /etc/apt/sources.list.d/pgdg.list
RUN mkdir -p /docker-entrypoint-initdb.d
# Install ``python-software-properties``, ``software-properties-common`` and PostgreSQL 9.3
# There are some warnings (in red) that show up during the build. You can hide
# them by prefixing each apt-get statement with DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y python-software-properties software-properties-common postgresql-9.3 postgresql-client-9.3 postgresql-contrib-9.3
# Note: The official Debian and Ubuntu images automatically ``apt-get clean``
# after each ``apt-get``
# Run the rest of the commands as the ``postgres`` user created by the ``postgres-9.3`` package when it was ``apt-get installed``
USER postgres
# Create a PostgreSQL role named ``docker`` with ``docker`` as the password and
# then create a database `docker` owned by the ``docker`` role.
# Note: here we use ``&&\`` to run commands one after the other - the ``\``
# allows the RUN command to span multiple lines.
RUN /etc/init.d/postgresql start &&\
psql --command "CREATE USER docker WITH SUPERUSER PASSWORD 'docker';" &&\
createdb -O docker marketalerts
# Adjust PostgreSQL configuration so that remote connections to the
# database are possible.
RUN echo "host all all 0.0.0.0/0 md5" >> /etc/postgresql/9.3/main/pg_hba.conf
# And add ``listen_addresses`` to ``/etc/postgresql/9.3/main/postgresql.conf``
RUN echo "listen_addresses='*'" >> /etc/postgresql/9.3/main/postgresql.conf
# Expose the PostgreSQL port
EXPOSE 5432
# Add VOLUMEs to allow backup of config, logs and databases
VOLUME ["/etc/postgresql", "/var/log/postgresql", "/var/lib/postgresql"]
# Set the default command to run when starting the container
CMD ["/usr/lib/postgresql/9.3/bin/postgres", "-D", "/var/lib/postgresql/9.3/main", "-c", "config_file=/etc/postgresql/9.3/main/postgresql.conf"]
CMD ["postgres"]

View File

@@ -1,10 +1,43 @@
# web
# MarketAlert
The purpose of this project is to build a web application that enables subscribing to notifications when new products are published on various ad based marketplaces. The MVP will be only based on OLX.ba
Run postgres image with:
docker run --name pg_test -d -p 5432:5432 marketalerts
## Setup
Run with:
$ npm start
### Setup with npm commands
1. Install packages
`npm install`
2. Run setup script
`npm run setup`
this will create and run postgres image and then execute migrations
3. Run app
`npm start` to run app without restart on changes or
`npm run start-mon` to run app with automatic restart on code change
### Manual setup
1. Create postgres docker image
`docker build -t marketalerts .`
2. Run postgres image with
`docker run --name pg_marketalerts -d -p 5432:5432 marketalerts`
3. Install packages
`npm install`
4. Run migrations from `app` folder
`npm run migrate` or `npx sequelize db:migrate`
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

@@ -7,9 +7,9 @@
"dialect": "postgres"
},
"test": {
"use_env_variable": "JAWSDB_URL"
"use_env_variable": "DATABASE_URL"
},
"production": {
"use_env_variable": "JAWSDB_URL"
"use_env_variable": "DATABASE_URL"
}
}

View File

@@ -1,7 +0,0 @@
const getDobrodosli = (req,res) => {
res.render('dobrodosli', { nextStep: '/vrstanekretnine' } );
}
module.exports = {
getDobrodosli
};

View File

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

View File

@@ -1,41 +0,0 @@
const db = require('../models/index');
const { currentRERequest } = require('../helpers/url');
const gradovi = [
{"ime":" Sarajevo","id":"sarajevo"},
{"ime":" Unsko-sanski","id":"unskosanski"},
{"ime":" Posavski","id":"posavski"},
{"ime":" Tuzlanski","id":"tuzlanski"},
{"ime":" Zeničko-dobojski","id":"zenickodobojski"},
{"ime":" Bosansko-podrinjski","id":"bosanskopodrinjski"},
{"ime":" Srednjobosanski","id":"srednjobosanski"},
{"ime":" Hercegovačko-neretvanski","id":"hercegovackoneretvanski"},
{"ime":" Zapadno-hercegovački","id":"zapadnohercegovacki"},
{"ime":" Livanjski","id":"livanjski"},
{"ime":" Banjalučka","id":"banjalučka"},
{"ime":" Dobojsko-Bijeljinska","id":"dobojskobijeljinska"},
{"ime":" Sarajevsko-Zvornička","id":"sarajevskozvornicka"},
{"ime":" Trebinjsko-Fočanska","id":"trebinjskofocanska"},
{"ime":"Distrikt Brčko","id":"distriktbrcko"},
];
const getGrad = (req,res) => {
const nextStep = req.query.nextStep || '/';
res.render('grad', {
nextStep,
gradovi
});
}
const postGrad = async (req, res) => {
let request = await currentRERequest(req);
request.city = req.body.grad;
await request.save();
res.send("Result is " + JSON.stringify(request));
}
module.exports = {
getGrad,
postGrad
};

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

@@ -0,0 +1,179 @@
const { currentSearchRequest } = require("../helpers/url");
const { isValidEmail } = require("../helpers/email");
const {
notifyForNewSearchRequest
} = require("../services/notificationService");
const { AD_CATEGORY, AD_TYPE } = require("../common/enums");
const getQueryReviewData = searchRequest => {
const {
id,
adType,
realEstateType,
sizeMin,
sizeMax,
gardenSizeMin,
gardenSizeMax,
priceMin,
priceMax
} = searchRequest.dataValues;
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 realEstateTypeTitle = realEstateTypeObject
? `[${adTypeTitle}] ${realEstateTypeObject.title}`
: "-";
const locationTitle = "Promjenite lokaciju";
const sizeTitle = `${sizeMin} - ${sizeMax} m2`;
const gardenSizeTitle = enableGardenSizeEdit
? `${gardenSizeMin} - ${gardenSizeMax} m2`
: "-";
const priceTitle = `${priceMin} - ${priceMax} KM`;
return [
{
id: "realEstateType",
title: realEstateTypeTitle,
url: `/vrstanekretnine/${id}?nextStep=filteri`
},
{
id: "location",
title: locationTitle,
url: `/lokacija/${id}?nextStep=pregled`
},
{
id: "size",
title: sizeTitle,
url: `/filteri/${id}?nextStep=pregled`
},
{
id: "gardenSize",
title: gardenSizeTitle,
url: enableGardenSizeEdit ? `/filteri/${id}?nextStep=pregled` : ""
},
{
id: "price",
title: priceTitle,
url: `/filteri/${id}?nextStep=pregled`
}
].filter(data => data.title != "-");
};
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,
queryReviewData,
title,
email,
error
});
};
const postQueryReview = async (req, res) => {
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);
};
module.exports = {
getQueryReview,
postQueryReview
};

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

@@ -0,0 +1,81 @@
const { currentSearchRequest } = require("../helpers/url");
const { createSearchRequest } = require("../helpers/db/searchRequest");
const { AD_CATEGORY, AD_TYPE } = require("../common/enums");
const getRealEstateTypes = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
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 searchRequest = await currentSearchRequest(req);
const adType = parseInt(req.body.adType);
const adTypeStringIds = {
[AD_TYPE.AD_TYPE_SALE.id]: AD_TYPE.AD_TYPE_SALE.stringId,
[AD_TYPE.AD_TYPE_RENT.id]: AD_TYPE.AD_TYPE_RENT.stringId
};
const adTypeStringId =
adTypeStringIds[adType] || AD_TYPE.AD_TYPE_SALE.stringId;
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 = {
getRealEstateTypes,
postRealEstateTypes
};

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

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

View File

@@ -1,35 +0,0 @@
const db = require('../models/index');
const vrsteNekretnina = [
{ ime: "Kuća", id: "kuca" },
{ ime: "Stan", id: "stan" },
{ ime: "Vikendica", id: "vikendica" }
];
const getVrstaNekretnine = (req,res) => {
const nextStep = req.query.nextStep;
res.render('vrsta_nekretnine', {
nextStep,
vrste: vrsteNekretnina
});
}
const postVrstaNekretnine = (req, res) => {
let nextStep = req.query.nextStep;
db.RealEstateRequest.create({
realEstateType: req.body.vrsta
}).then( (result) => {
nextStep = nextStep || `/grad/${result.uniqueId}`;
res.redirect(nextStep);
}).catch( (e) => {
res.send(e);
});
}
module.exports = {
getVrstaNekretnine,
postVrstaNekretnine
};

View File

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

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;

931
app/helpers/codes.js Normal file
View File

@@ -0,0 +1,931 @@
const regions = [
{
name: " Sarajevo",
id: "sarajevo",
olxid: "9",
municipalities: [
{
name: "Hadžići",
id: "hadii",
olxid: "3817"
},
{
name: "Ilidža",
id: "ilida",
olxid: "3879"
},
{
name: "Ilijaš",
id: "ilija",
olxid: "3892"
},
{
name: "Sarajevo - Centar",
id: "sarajevocentar",
olxid: "3812"
},
{
name: "Sarajevo-Novi Grad",
id: "sarajevonovigrad",
olxid: "3969"
},
{
name: "Sarajevo-Novo Sarajevo",
id: "sarajevonovosarajevo",
olxid: "5896"
},
{
name: "Sarajevo-Stari Grad",
id: "sarajevostarigrad",
olxid: "4048"
},
{
name: "Trnovo",
id: "trnovo",
olxid: "4063"
},
{
name: "Vogošća",
id: "vogoa",
olxid: "4126"
}
]
},
{
name: " Unsko-sanski",
id: "unskosanski",
olxid: "1",
municipalities: [
{
name: "Bihać",
id: "biha",
olxid: "75"
},
{
name: "Bosanska Krupa",
id: "bosanskakrupa",
olxid: "373"
},
{
name: "Bosanski Petrovac",
id: "bosanskipetrovac",
olxid: "504"
},
{
name: "Bužim",
id: "buim",
olxid: "374"
},
{
name: "Cazin",
id: "cazin",
olxid: "857"
},
{
name: "Ključ",
id: "klju",
olxid: "2362"
},
{
name: "Sanski Most",
id: "sanskimost",
olxid: "3738"
},
{
name: "Velika Kladuša",
id: "velikakladua",
olxid: "5122"
}
]
},
{
name: " Posavski",
id: "posavski",
olxid: "2",
municipalities: [
{
name: "Domaljevac",
id: "domaljevac",
olxid: "6144"
},
{
name: "Odžak",
id: "odak",
olxid: "424"
},
{
name: "Orašje",
id: "oraje",
olxid: "3252"
},
{
name: "Šamac",
id: "amac",
olxid: "540"
}
]
},
{
name: " Tuzlanski",
id: "tuzlanski",
olxid: "3",
municipalities: [
{
name: "Banovići",
id: "banovii",
olxid: "2"
},
{
name: "Doboj-Istok",
id: "dobojistok",
olxid: "1090"
},
{
name: "Gradačac",
id: "gradaac",
olxid: "1854"
},
{
name: "Gračanica",
id: "graanica",
olxid: "1826"
},
{
name: "Kalesija",
id: "kalesija",
olxid: "2129"
},
{
name: "Kladanj",
id: "kladanj",
olxid: "2319"
},
{
name: "Lukavac",
id: "lukavac",
olxid: "2840"
},
{
name: "Sapna",
id: "sapna",
olxid: "5699"
},
{
name: "Srebrenik",
id: "srebrenik",
olxid: "4391"
},
{
name: "Teočak",
id: "teoak",
olxid: "5010"
},
{
name: "Tuzla",
id: "tuzla",
olxid: "4944"
},
{
name: "Čelić",
id: "eli",
olxid: "2801"
},
{
name: "Živinice",
id: "ivinice",
olxid: "5774"
}
]
},
{
name: " Zeničko-dobojski",
id: "zenickodobojski",
olxid: "4",
municipalities: [
{
name: "Breza",
id: "breza",
olxid: "704"
},
{
name: "Doboj-Jug",
id: "dobojjug",
olxid: "1122"
},
{
name: "Kakanj",
id: "kakanj",
olxid: "2022"
},
{
name: "Maglaj",
id: "maglaj",
olxid: "2941"
},
{
name: "Olovo",
id: "olovo",
olxid: "1925"
},
{
name: "Tešanj",
id: "teanj",
olxid: "4594"
},
{
name: "Usora",
id: "usora",
olxid: "1087"
},
{
name: "Vareš",
id: "vare",
olxid: "5037"
},
{
name: "Visoko",
id: "visoko",
olxid: "5171"
},
{
name: "Zavidovići",
id: "zavidovii",
olxid: "5548"
},
{
name: "Zenica",
id: "zenica",
olxid: "4571"
},
{
name: "Žepče",
id: "epe",
olxid: "2940"
}
]
},
{
name: " Bosansko-podrinjski",
id: "bosanskopodrinjski",
olxid: "5",
municipalities: [
{
name: "Foča",
id: "foa",
olxid: "1289"
},
{
name: "Goražde",
id: "gorade",
olxid: "1588"
},
{
name: "Pale",
id: "pale",
olxid: "3546"
}
]
},
{
name: " Srednjobosanski",
id: "srednjobosanski",
olxid: "6",
municipalities: [
{
name: "Bugojno",
id: "bugojno",
olxid: "732"
},
{
name: "Busovača",
id: "busovaa",
olxid: "810"
},
{
name: "Dobretići",
id: "dobretii",
olxid: "4151"
},
{
name: "Donji Vakuf",
id: "donjivakuf",
olxid: "1160"
},
{
name: "Fojnica",
id: "fojnica",
olxid: "1407"
},
{
name: "Gornji Vakuf - Uskoplje",
id: "gornjivakufuskoplje",
olxid: "1775"
},
{
name: "Jajce",
id: "jajce",
olxid: "1960"
},
{
name: "Kiseljak",
id: "kiseljak",
olxid: "2237"
},
{
name: "Kreševo",
id: "kreevo",
olxid: "2608"
},
{
name: "Novi Travnik",
id: "novitravnik",
olxid: "3477"
},
{
name: "Travnik",
id: "travnik",
olxid: "4678"
},
{
name: "Vitez",
id: "vitez",
olxid: "5422"
}
]
},
{
name: " Hercegovačko-neretvanski",
id: "hercegovackoneretvanski",
olxid: "7",
municipalities: [
{
name: "Grad Mostar",
id: "gradmostar",
olxid: "3017"
},
{
name: "Jablanica",
id: "jablanica",
olxid: "1930"
},
{
name: "Konjic",
id: "konjic",
olxid: "2169"
},
{
name: "Neum",
id: "neum",
olxid: "3111"
},
{
name: "Prozor",
id: "prozor",
olxid: "3421"
},
{
name: "Ravno",
id: "ravno",
olxid: "4769"
},
{
name: "Stolac",
id: "stolac",
olxid: "4439"
},
{
name: "Čapljina",
id: "apljina",
olxid: "947"
},
{
name: "Čitluk",
id: "itluk",
olxid: "1009"
}
]
},
{
name: " Zapadno-hercegovački",
id: "zapadnohercegovacki",
olxid: "8",
municipalities: [
{
name: "Grude",
id: "grude",
olxid: "1892"
},
{
name: "Ljubuški",
id: "ljubuki",
olxid: "2905"
},
{
name: "Posušje",
id: "posuje",
olxid: "3268"
},
{
name: "Široki Brijeg",
id: "irokibrijeg",
olxid: "2708"
}
]
},
{
name: " Livanjski",
id: "livanjski",
olxid: "10",
municipalities: [
{
name: "Bosansko Grahovo",
id: "bosanskograhovo",
olxid: "560"
},
{
name: "Drvar",
id: "drvar",
olxid: "4640"
},
{
name: "Glamoč",
id: "glamo",
olxid: "1533"
},
{
name: "Kupres",
id: "kupres",
olxid: "2635"
},
{
name: "Livno",
id: "livno",
olxid: "2741"
},
{
name: "Tomislavgrad",
id: "tomislavgrad",
olxid: "1228"
}
]
},
{
name: " Banjalučka",
id: "banjalučka",
olxid: "14",
municipalities: [
{
name: "Banja Luka",
id: "banjaluka",
olxid: "21"
},
{
name: "Gradiška",
id: "gradika",
olxid: "305"
},
{
name: "Istočni Drvar",
id: "istonidrvar",
olxid: "4662"
},
{
name: "Jezero",
id: "jezero",
olxid: "1965"
},
{
name: "Kneževo",
id: "kneevo",
olxid: "4147"
},
{
name: "Kostajnica",
id: "kostajnica",
olxid: "6142"
},
{
name: "Kotor Varoš",
id: "kotorvaro",
olxid: "2574"
},
{
name: "Kozarska Dubica",
id: "kozarskadubica",
olxid: "244"
},
{
name: "Krupa na uni",
id: "krupanauni",
olxid: "382"
},
{
name: "Kupres ",
id: "kupres",
olxid: "2654"
},
{
name: "Laktaši",
id: "laktai",
olxid: "2671"
},
{
name: "Mrkonjić Grad",
id: "mrkonjigrad",
olxid: "3073"
},
{
name: "Novi Grad",
id: "novigrad",
olxid: "444"
},
{
name: "Oštra Luka",
id: "otraluka",
olxid: "3737"
},
{
name: "Petrovac",
id: "petrovac",
olxid: "515"
},
{
name: "Prijedor",
id: "prijedor",
olxid: "3287"
},
{
name: "Prnjavor",
id: "prnjavor",
olxid: "3358"
},
{
name: "Ribnik",
id: "ribnik",
olxid: "2365"
},
{
name: "Srbac",
id: "srbac",
olxid: "4271"
},
{
name: "Čelinac",
id: "elinac",
olxid: "979"
},
{
name: "Šipovo",
id: "ipovo",
olxid: "4509"
}
]
},
{
name: " Dobojsko-Bijeljinska",
id: "dobojskobijeljinska",
olxid: "15",
municipalities: [
{
name: "Bijeljina",
id: "bijeljina",
olxid: "123"
},
{
name: "Bosanski Brod",
id: "bosanskibrod",
olxid: "421"
},
{
name: "Derventa",
id: "derventa",
olxid: "1030"
},
{
name: "Doboj",
id: "doboj",
olxid: "1088"
},
{
name: "Donji Žabar",
id: "donjiabar",
olxid: "3254"
},
{
name: "Lopare",
id: "lopare",
olxid: "2800"
},
{
name: "Lukavac",
id: "lukavac",
olxid: "6029"
},
{
name: "Modriča",
id: "modria",
olxid: "2996"
},
{
name: "Pelagićevo",
id: "pelagievo",
olxid: "1856"
},
{
name: "Petrovo",
id: "petrovo",
olxid: "1827"
},
{
name: "Stanari",
id: "stanari",
olxid: "1148"
},
{
name: "Teslić",
id: "tesli",
olxid: "4549"
},
{
name: "Tešanj",
id: "teanj",
olxid: "4636"
},
{
name: "Travnik",
id: "travnik",
olxid: "4692"
},
{
name: "Tuzla",
id: "tuzla",
olxid: "4966"
},
{
name: "Ugljevik",
id: "ugljevik",
olxid: "5009"
},
{
name: "Vukosavlje",
id: "vukosavlje",
olxid: "3197"
},
{
name: "Šamac",
id: "amac",
olxid: "539"
}
]
},
{
name: " Sarajevsko-Zvornička",
id: "sarajevskozvornicka",
olxid: "16",
municipalities: [
{
name: "Bratunac",
id: "bratunac",
olxid: "595"
},
{
name: "Han Pijesak",
id: "hanpijesak",
olxid: "1904"
},
{
name: "Ilijaš",
id: "ilija",
olxid: "3947"
},
{
name: "Istočni Stari Grad",
id: "istonistarigrad",
olxid: "4049"
},
{
name: "Kasindo",
id: "kasindo",
olxid: "3880"
},
{
name: "Kladanj",
id: "kladanj",
olxid: "2325"
},
{
name: "Lukavica",
id: "lukavica",
olxid: "3971"
},
{
name: "Milići",
id: "milii",
olxid: "6143"
},
{
name: "Olovo",
id: "olovo",
olxid: "3221"
},
{
name: "Osmaci",
id: "osmaci",
olxid: "2128"
},
{
name: "Pale",
id: "pale",
olxid: "3978"
},
{
name: "Rogatica",
id: "rogatica",
olxid: "3529"
},
{
name: "Rudo",
id: "rudo",
olxid: "3648"
},
{
name: "Sarajevo-Novi Grad",
id: "sarajevonovigrad",
olxid: "6069"
},
{
name: "Sokolac",
id: "sokolac",
olxid: "4183"
},
{
name: "Srebrenica",
id: "srebrenica",
olxid: "4310"
},
{
name: "Trnovo",
id: "trnovo",
olxid: "4067"
},
{
name: "Ustiprača",
id: "ustipraa",
olxid: "1593"
},
{
name: "Višegrad",
id: "viegrad",
olxid: "5259"
},
{
name: "Vlasenica",
id: "vlasenica",
olxid: "5456"
},
{
name: "Zvornik",
id: "zvornik",
olxid: "5684"
},
{
name: "Šekovići",
id: "ekovii",
olxid: "4475"
},
{
name: "Žepa",
id: "epa",
olxid: "1906"
}
]
},
{
name: " Trebinjsko-Fočanska",
id: "trebinjskofocanska",
olxid: "17",
municipalities: [
{
name: "Berkovići",
id: "berkovii",
olxid: "4441"
},
{
name: "Bileća",
id: "bilea",
olxid: "183"
},
{
name: "Foča",
id: "foa",
olxid: "1287"
},
{
name: "Gacko",
id: "gacko",
olxid: "1462"
},
{
name: "Istočni Mostar",
id: "istonimostar",
olxid: "3038"
},
{
name: "Kalinovik",
id: "kalinovik",
olxid: "2164"
},
{
name: "Ljubinje",
id: "ljubinje",
olxid: "2884"
},
{
name: "Nevesinje",
id: "nevesinje",
olxid: "3138"
},
{
name: "Trebinje",
id: "trebinje",
olxid: "4766"
},
{
name: "Čajniče",
id: "ajnie",
olxid: "911"
}
]
},
{
name: "Distrikt Brčko",
id: "distriktbrcko",
olxid: "12",
municipalities: [
{
name: "Brčko",
id: "brko",
olxid: "645"
}
]
}
];
const getRegions = () => {
return regions.map(g => ({ name: g.name, id: g.id, olxid: g.olxid }));
};
const getRegion = regionId => {
return regions.find(region => region.id === regionId);
};
const getRegionName = regionId => {
const region = getRegion(regionId);
return region && region.name ? region.name : null;
};
const getMunicipalitiesForRegion = regionId => {
const region = getRegion(regionId);
return region && region.municipalities ? region.municipalities : null;
};
const getMunicipality = (regionId, municipalityId) => {
const region = getRegion(regionId);
if (!region) {
return null;
}
const municipality = region.municipalities.find(
municipality => municipality.id === municipalityId
);
if (!municipality) {
return null;
}
return municipality;
};
const getMunicipalityName = (regionId, municipalityId) => {
const region = getRegion(regionId);
if (!region) {
return null;
}
const municipality = region.municipalities.find(
municipality => municipality.id === municipalityId
);
if (!municipality) {
return null;
}
return municipality.name;
};
module.exports = {
getRegion,
getRegions,
getRegionName,
getMunicipalitiesForRegion,
getMunicipalityName,
getMunicipality
};

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
};

8
app/helpers/email.js Normal file
View File

@@ -0,0 +1,8 @@
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 = {
isValidEmail
};

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
};

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,14 +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 request = await db.RealEstateRequest.findOne({ where: {uniqueId} });
console.log("Request ", request);
return request;
}
const currentSearchRequest = async req => {
const searchRequestId =
req && req.params ? req.params["searchRequestId"] : null;
if (!searchRequestId) return null;
return await getSearchRequest(searchRequestId);
};
module.exports = {
currentRERequest
}
currentSearchRequest
};

View File

@@ -1,8 +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, last_date } = 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
var 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,
@@ -12,8 +12,7 @@ module.exports = {
type: Sequelize.UUID
},
realEstateType: {
type: Sequelize.ENUM,
values: ['kuca','stan','vikendica','plac','poslovni_prostor','apartman','garaza']
type: Sequelize.STRING
},
email: {
type: Sequelize.STRING
@@ -29,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',
"RealEstateRequests",
"city",
Sequelize.STRING
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn(
'RealEstateRequests',
'city'
);
return queryInterface.removeColumn("RealEstateRequests", "city");
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
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]) => {
/// No result
});
}
};

View File

@@ -0,0 +1,21 @@
"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
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize
.query('ALTER TABLE "RealEstateRequests" DROP COLUMN bounding_box')
.then(([results, metadata]) => {
/// No result
});
}
};

View File

@@ -0,0 +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 }
)
]);
});
},
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

@@ -0,0 +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 }
)
]);
});
},
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

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

View File

@@ -0,0 +1,70 @@
"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 }
)
]);
});
},
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

@@ -0,0 +1,59 @@
"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 }
)
]);
});
},
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

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("MarketAlerts", "realEstateType", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
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,15 +0,0 @@
'use strict';
module.exports = (sequelize, DataTypes) => {
const MarketAlert = sequelize.define('MarketAlert', {
olxUrl: DataTypes.STRING,
lastDate: 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,20 +0,0 @@
'use strict';
module.exports = (sequelize, DataTypes) => {
const RealEstateRequest = sequelize.define('RealEstateRequest', {
uniqueId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
allowNull: false
},
realEstateType: {
type: DataTypes.ENUM,
values: ['kuca','stan','vikendica','plac','poslovni_prostor','apartman','garaza']
},
email: DataTypes.STRING,
city: DataTypes.STRING,
}, {});
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.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

122
app/public/images/logo.svg Normal file
View File

@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="61"
height="94"
viewBox="0 0 61 94"
version="1.1"
id="svg29"
sodipodi:docname="logo2.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)">
<metadata
id="metadata33">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1853"
inkscape:window-height="1025"
id="namedview31"
showgrid="false"
inkscape:zoom="5.6568543"
inkscape:cx="29.58234"
inkscape:cy="42.092869"
inkscape:window-x="67"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg29" />
<!-- Generator: Sketch 51.3 (57544) - http://www.bohemiancoding.com/sketch -->
<desc
id="desc2">Created with Sketch.</desc>
<defs
id="defs4" />
<g
id="g938"
transform="translate(4.6566166)">
<g
id="Group"
transform="translate(21.468225,75.05246)"
style="fill:#02adba;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1">
<path
style="fill:#02adba;fill-opacity:1"
d="m 2.8937203,1.377828 h 0.00886 V 16.18948 h -0.00886 c -0.080215,0.775019 -0.6954121,1.377828 -1.4424314,1.377828 -0.74701929,0 -1.36221635,-0.602809 -1.4424314,-1.377828 H 0 V 1.377828 H 0.0088575 C 0.08907255,0.60280831 0.70426961,0 1.4512889,0 2.1983082,0 2.8135053,0.60280831 2.8937203,1.377828 Z"
id="Combined-Shape"
inkscape:connector-curvature="0" />
</g>
<g
id="Group-Copy"
transform="translate(40.284746,75.05246)"
style="fill:#02adba;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1">
<path
style="fill:#02adba;fill-opacity:1"
d="m 2.8937203,1.377828 h 0.00886 V 16.18948 h -0.00886 c -0.080215,0.775019 -0.6954121,1.377828 -1.4424314,1.377828 -0.74701929,0 -1.36221635,-0.602809 -1.4424314,-1.377828 H 0 V 1.377828 H 0.0088575 C 0.08907255,0.60280831 0.70426961,0 1.4512889,0 2.1983082,0 2.8135053,0.60280831 2.8937203,1.377828 Z"
id="path8"
inkscape:connector-curvature="0" />
</g>
<g
id="Group-2"
transform="translate(26.045022,75.05246)"
style="fill:#02adba;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1">
<path
style="fill:#02adba;fill-opacity:1"
sodipodi:nodetypes="cccsscccsscccsc"
d="M 4.7335014,16.043249 0.01556995,2.0709177 v 0 C -0.03604093,1.9151618 -0.08984375,1.7196494 -0.08984375,1.5461504 -0.08984375,0.69007771 0.68116079,0 1.5214152,0 2.3524826,0 2.7388538,0.67107889 3.0425868,1.5220354 L 6.255723,11.044343 9.4667171,1.5292951 C 9.7627803,0.71794799 10.154547,0 10.987999,0 c 0.840254,0 1.603446,0.68226521 1.603446,1.5383379 0,0.1793218 -0.04867,0.3879772 -0.103645,0.5481862 v 0 L 7.776097,16.026251 c -0.6976248,1.816338 -0.6840821,1.541057 -1.5213901,1.541057 -0.831732,0 -0.9926068,0.05035 -1.5212055,-1.524059 z"
id="path11"
inkscape:connector-curvature="0" />
</g>
<path
sodipodi:nodetypes="ccccsscccsscccccscscccssscc"
inkscape:connector-curvature="0"
id="path14"
d="m 12.013315,76.606127 v 4.050014 l 4.717498,-5.019565 v 0 c 0.404985,-0.305017 0.852764,-0.412505 1.290558,-0.412505 0.899311,0 1.590893,0.796979 1.590893,1.653051 0,0.589329 -0.373586,0.895853 -0.854196,1.364015 l -5.796302,6.120383 5.715392,5.649676 c 0.532765,0.486219 0.935106,0.782903 0.935106,1.402972 0,0.856073 -0.729035,1.550057 -1.628346,1.550057 -0.420934,0 -0.861077,0.0072 -1.216312,-0.275429 v 0 l -4.754291,-4.690453 v 3.249235 h -0.0031 c 0.0021,0.03199 0.0031,0.06425 0.0031,0.09674 0,0.856072 -0.729035,1.550056 -1.628346,1.550056 -0.8993109,-10e-7 -1.6283459,-0.693985 -1.6283459,-1.550057 0,-0.03249 10e-4,-0.06475 0.0031,-0.09674 h -0.0031 v -14.64145 -0.0036 c 0,-0.856083 0.729035,-1.550067 1.6283459,-1.550067 0.899311,0 1.628346,0.693984 1.628346,1.550057 0,0.0012 -1e-6,0.0024 -4e-6,0.0036 z"
style="fill:#02adba;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1" />
</g>
<g
style="fill:#02adba;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1"
id="Group-5-Copy"
transform="translate(0.30033447)">
<path
inkscape:connector-curvature="0"
id="Page-1-Copy-3"
d="m 28.864505,0.71910556 c 0.913861,-0.49697651 2.013643,-0.49697651 2.927503,0 L 58.602992,15.299478 c 1.00074,0.544224 1.624856,1.599801 1.624856,2.748147 V 52.95707 c 0,1.148497 -0.624278,2.204188 -1.625223,2.748347 L 31.791641,70.28107 c -0.913667,0.49671 -2.013102,0.49671 -2.926768,0 L 2.053889,55.705417 C 1.052944,55.161258 0.42866553,54.105567 0.42866553,52.95707 V 18.047625 c 0,-1.148346 0.62411557,-2.203923 1.62485607,-2.748147 z M 30.328257,3.4672523 3.5172731,18.047625 V 52.95707 L 30.328257,67.532724 57.13924,52.95707 V 18.047625 Z"
style="fill:#02adba;fill-opacity:1" />
<path
inkscape:connector-curvature="0"
transform="matrix(-1,0,0,1,38.607594,0)"
id="Page-1-Copy-2"
d="m 6.6881981,8.5296646 c 0,-1.1837877 1.2534136,-1.9364737 2.2824613,-1.3706383 L 31.113251,19.334421 c 0.496869,0.273211 0.806146,0.799054 0.806146,1.370639 v 29.035897 c 0,0.571661 -0.309358,1.097561 -0.806331,1.37074 L 8.970475,63.283151 C 7.9414326,63.848801 6.6881981,63.096106 6.6881981,61.912412 Z M 9.7768057,11.155344 V 59.287138 L 28.830789,48.813446 V 21.632428 Z"
style="fill:#02adba;fill-opacity:1" />
<path
inkscape:connector-curvature="0"
transform="rotate(4,36.513012,12.39682)"
id="Path-85"
d="m 29.442604,10.590184 12.454251,6.520214 c 0.879716,0.424975 1.970417,0.118743 2.436149,-0.683987 0.465733,-0.802729 0.130132,-1.797981 -0.749584,-2.222955 L 31.12917,7.6832415 C 30.249453,7.2582668 29.158752,7.564498 28.69302,8.367228 c -0.465732,0.8027299 -0.130132,1.797981 0.749584,2.222956 z"
style="fill:#02adba;fill-opacity:1" />
</g>
<path
style="fill:#02adba;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1"
inkscape:connector-curvature="0"
id="Path-87"
d="m 7.8821311,47.17753 c -0.7181691,0.486659 -0.8975584,1.451387 -0.4006772,2.15478 0.4968812,0.703394 1.4818741,0.879093 2.2000432,0.392434 L 31.518752,34.926994 c 0.939679,-0.636761 0.900745,-2.009481 -0.07358,-2.594168 L 9.6079198,19.228381 c -0.744646,-0.446859 -1.718161,-0.217874 -2.1744066,0.511452 -0.4562456,0.729326 -0.2224509,1.682812 0.5221952,2.129671 L 27.723653,33.732165 Z" />
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

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