Compare commits

..

1 Commits

Author SHA1 Message Date
MirnaM
a0b4bc7879 Scrape lat lng from olx 2019-05-06 15:13:13 +02:00
123 changed files with 2037 additions and 8161 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,11 +1,48 @@
FROM postgres:11.3
#
# example Dockerfile for https://docs.docker.com/engine/examples/postgresql_service/
#
ENV POSTGIS_MAJOR 2.4
FROM ubuntu:16.04
RUN apt-get update \
&& apt-get --assume-yes install software-properties-common postgis\
&& rm -rf /var/lib/apt/lists/
# 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 mkdir -p /docker-entrypoint-initdb.d
# 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
CMD ["postgres"]
# 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"]

View File

@@ -1,43 +1,10 @@
# MarketAlert
# web
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
## Setup
Run postgres image with:
docker run --name pg_test -d -p 5432:5432 marketalerts
### 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
Run with:
$ npm start

View File

@@ -1,152 +0,0 @@
const PRICE_SLIDER_OPTIONS = {
start: [50000, 85000],
range: {
min: [0],
max: [300000]
},
step: 1000,
connect: true,
tooltips: 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,
tooltips: true
};
const GARDEN_SIZE_SLIDER_OPTIONS = {
start: [100, 1000],
range: {
min: [0],
max: [10000]
},
step: 100,
connect: true,
tooltips: true
};
const LAND_SIZE_SLIDER_OPTIONS = {
start: [5000, 15000],
range: {
min: [0],
max: [100000]
},
step: 100,
connect: true,
tooltips: true
};
const GARAGE_SIZE_SLIDER_OPTIONS = {
start: [10, 20],
range: {
min: [0],
max: [150]
},
step: 2,
connect: true,
tooltips: true
};
const GARAGE_PRICE_SLIDER_OPTIONS = {
start: [2000, 10000],
range: {
min: [0],
max: [100000]
},
step: 500,
connect: true,
tooltips: true
};
const AD_TYPE = {
AD_TYPE_SALE: "SALE",
AD_TYPE_RENT: "RENT"
};
const AD_CATEGORY = {
FLAT: {
id: "FLAT",
title: "Stan",
hasGardenSize: false,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
HOUSE: {
id: "HOUSE",
title: "Kuća",
hasGardenSize: true,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
},
OFFICE: {
id: "OFFICE",
title: "Kancelarija",
hasGardenSize: false,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
LAND: {
id: "LAND",
title: "Zemljište",
hasGardenSize: false,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: LAND_SIZE_SLIDER_OPTIONS
},
APARTMENT: {
id: "APARTMENT",
title: "Apartman",
hasGardenSize: false,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
GARAGE: {
id: "GARAGE",
title: "Garaža",
hasGardenSize: false,
priceSliderOptions: GARAGE_PRICE_SLIDER_OPTIONS,
sizeSliderOptions: GARAGE_SIZE_SLIDER_OPTIONS
},
COTTAGE: {
id: "COTTAGE",
title: "Vikendica",
hasGardenSize: true,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
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"
};
const CRAWLER_AD_TYPE = {
NONE: 0,
ALL: 1,
ONLY_SELL: 2,
ONLY_RENT: 3
};
module.exports = {
AD_TYPE,
AD_CATEGORY,
AD_STATUS,
AD_AGENCY,
CRAWLER_AD_TYPE
};

View File

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

View File

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

View File

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

View File

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

27
app/controllers/grad.js Normal file
View File

@@ -0,0 +1,27 @@
const db = require('../models/index');
const { currentRERequest } = require('../helpers/url');
const { regions } = require('../helpers/codes');
const gradovi = regions();
const getGrad = (req,res) => {
const nextStep = req.query.nextStep || '/';
res.render('grad', {
nextStep,
gradovi
});
}
const postGrad = async (req, res) => {
const request = await currentRERequest(req);
const nextStep = req.query.nextStep || `/mjesto/${request.uniqueId}`;
request.city = req.body.grad;
await request.save();
res.redirect(nextStep)
}
module.exports = {
getGrad,
postGrad
};

View File

@@ -1,57 +0,0 @@
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
};

26
app/controllers/mjesto.js Normal file
View File

@@ -0,0 +1,26 @@
const db = require('../models/index');
const { currentRERequest } = require('../helpers/url');
const { places } = require('../helpers/codes');
const getMjesto = async (req,res) => {
let request = await currentRERequest(req);
const mjesta = places(request.city);
const nextStep = req.query.nextStep || '/';
res.render('mjesto', {
nextStep,
mjesta
});
}
const postMjesto = async (req, res) => {
let request = await currentRERequest(req);
request.place = req.body.mjesto;
console.log("AAA ", req.body);
await request.save();
res.send("Result is " + JSON.stringify(request));
}
module.exports = {
getMjesto,
postMjesto
};

View File

@@ -1,135 +0,0 @@
const { currentSearchRequest } = require("../helpers/url");
const { isValidEmail } = require("../helpers/email");
const {
notifyForNewSearchRequest
} = require("../services/notificationService");
const { AD_CATEGORY } = require("../common/enums");
const getQueryReviewData = searchRequest => {
const {
id,
realEstateType,
sizeMin,
sizeMax,
gardenSizeMin,
gardenSizeMax,
priceMin,
priceMax
} = searchRequest.dataValues;
const realEstateTypeObject = AD_CATEGORY[realEstateType];
const enableGardenSizeEdit = realEstateTypeObject
? realEstateTypeObject.hasGardenSize
: false;
const realEstateTypeTitle = realEstateTypeObject
? 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;
await searchRequest.save();
await notifyForNewSearchRequest(searchRequest);
res.redirect(nextStep);
};
module.exports = {
getQueryReview,
postQueryReview
};

View File

@@ -1,97 +0,0 @@
const { currentSearchRequest } = require("../helpers/url");
const { AD_CATEGORY } = 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 {
realEstateType,
priceMin,
priceMax,
sizeMin,
sizeMax,
gardenSizeMin,
gardenSizeMax
} = searchRequest;
const category = AD_CATEGORY[realEstateType] || AD_CATEGORY.FLAT;
const {
hasGardenSize,
priceSliderOptions,
sizeSliderOptions,
gardenSizeSliderOptions
} = category;
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.priceFilterMin) || 0;
const priceMax = parseInt(req.body.priceFilterMax) || 0;
const sizeMin = parseInt(req.body.sizeFilterMin) || 0;
const sizeMax = parseInt(req.body.sizeFilterMax) || 0;
//TODO: Filter validation
searchRequest.priceMin = priceMin;
searchRequest.priceMax = priceMax;
searchRequest.sizeMin = sizeMin;
searchRequest.sizeMax = sizeMax;
if (
req.body.gardenSizeFilterMin !== undefined &&
req.body.gardenSizeFilterMax !== undefined
) {
const gardenSizeMin = parseInt(req.body.gardenSizeFilterMin);
const gardenSizeMax = parseInt(req.body.gardenSizeFilterMax);
//TODO: Filter validation
searchRequest.gardenSizeMin = gardenSizeMin;
searchRequest.gardenSizeMax = gardenSizeMax;
}
await searchRequest.save();
res.redirect(nextStepUrl);
};
module.exports = {
getFilters,
postFilters
};

View File

@@ -1,46 +0,0 @@
const { currentSearchRequest } = require("../helpers/url");
const { createSearchRequest } = require("../helpers/db/searchRequest");
const { AD_CATEGORY } = require("../common/enums");
const getRealEstateTypes = (req, res) => {
const title = "Koju nekretninu tražite?";
const realEstateTypes = Object.keys(AD_CATEGORY).map(
category => AD_CATEGORY[category]
);
res.render("realEstateType", { realEstateTypes, title });
};
const postRealEstateTypes = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
//TODO: check if selected real estate type is valid
const selectedRealEstateType = req.body.realEstateType || null;
const nextStepPage = req.query.nextStep || "lokacija";
let nextStepUrl = "";
if (searchRequest && searchRequest.id) {
nextStepUrl = `/${nextStepPage}/${searchRequest.id}`;
searchRequest.realEstateType = selectedRealEstateType;
await searchRequest.save();
} else {
try {
const newSearchRequest = await createSearchRequest({
realEstateType: selectedRealEstateType
});
nextStepUrl = `/${nextStepPage}/${newSearchRequest.id}`;
} catch (error) {
console.log(error);
nextStepUrl = `/`;
}
}
res.redirect(nextStepUrl);
};
module.exports = {
getRealEstateTypes,
postRealEstateTypes
};

View File

@@ -1,16 +0,0 @@
"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

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

View File

@@ -1,20 +0,0 @@
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

@@ -0,0 +1,35 @@
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

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

View File

@@ -1,37 +0,0 @@
"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("./specific/olx");
const { OLX_CONFIG } = require("./crawlerConfig");
const PostgresSaver = require("./savers/postgres");
const crawlers = [
new OlxCrawler(
[new 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
)
];
async function crawlAll() {
for (let crawler of crawlers) {
try {
return await crawler.crawl();
} catch (e) {
console.log("Error crawling. Trying next crawler! ", e);
return [];
}
}
}
module.exports = {
crawlAll
};

View File

@@ -1,42 +0,0 @@
"use strict";
require("dotenv").config({ path: __dirname + "/./../../.env" });
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 transformedCrawlerAdCategories = olxParsedCrawlerAdCategories
.map(categoryName =>
AD_CATEGORY[categoryName] ? AD_CATEGORY[categoryName].id : undefined
)
.filter(category => !!category);
const OLX_CONFIG = {
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: transformedCrawlerAdCategories,
OLX_IGNORED_USERNAMES: olxIgnoredUsernames || [],
OLX_DELAY_BETWEEN_PAGES: parseInt(process.env.OLX_DELAY_BETWEEN_PAGES) || 1000
};
module.exports = {
OLX_CONFIG
};

View File

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

View File

@@ -1,47 +0,0 @@
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

@@ -1,565 +0,0 @@
"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 } = 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"
},
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"
};
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) {
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 && urlCategoryPart) {
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) {
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:nth-child(3) > 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" };
}
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;
case "Izdavanje":
return AD_TYPE.AD_TYPE_RENT;
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;

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +0,0 @@
"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

@@ -1,74 +0,0 @@
"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

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

View File

@@ -1,8 +0,0 @@
const isValidEmail = email => {
const simpleEmailRegex = /^.+@.+\..+$/;
return email && email.length < 250 && simpleEmailRegex.test(email);
};
module.exports = {
isValidEmail
};

View File

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

View File

@@ -1,25 +0,0 @@
/**
* 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();
}
};
};

27
app/helpers/scraping.js Normal file
View File

@@ -0,0 +1,27 @@
let fetch = require("node-fetch");
const getRealEstateGeolocation = async (url) => {
let response = await fetch(url);
const body = await response.text();
let lat, long;
const googleMapRegex = new RegExp(/google.maps.LatLng\((.*?)\)/g);
const googleMapString = body.match(googleMapRegex);
if (googleMapString && googleMapString.length) {
const latLongRegex = new RegExp(/\((.*?)\)/g);
let latLongString = googleMapString[0].match(latLongRegex);
if (latLongString && latLongString.length) {
latLongString = latLongString[0].trim();
latLongString = latLongString.substr(1, latLongString.length - 2);
const latLongArray = latLongString.split(",");
if (latLongArray.length) {
lat = latLongArray[0];
long = latLongArray[1];
}
}
}
return { lat, long };
}
module.exports = {
getRealEstateGeolocation
};

View File

@@ -1,12 +1,14 @@
const { getSearchRequest } = require("./db/searchRequest");
const db = require('../models/index');
const currentSearchRequest = async req => {
const searchRequestId =
req && req.params ? req.params["searchRequestId"] : null;
if (!searchRequestId) return null;
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;
}
return await getSearchRequest(searchRequestId);
};
module.exports = {
currentSearchRequest
};
currentRERequest
}

View File

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

13
app/lib/convertToDate.js Normal file
View File

@@ -0,0 +1,13 @@
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;

42
app/lib/scraptheitems.js Normal file
View File

@@ -0,0 +1,42 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +0,0 @@
"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

@@ -1,13 +0,0 @@
"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

@@ -1,13 +0,0 @@
"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

@@ -1,19 +0,0 @@
"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

@@ -1,21 +0,0 @@
"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

@@ -1,48 +0,0 @@
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

@@ -1,147 +0,0 @@
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

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

View File

@@ -1,70 +0,0 @@
"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

@@ -1,59 +0,0 @@
"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

@@ -1,13 +0,0 @@
"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

@@ -1,13 +0,0 @@
"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

@@ -1,13 +0,0 @@
"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

@@ -1,13 +0,0 @@
"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

@@ -1,13 +0,0 @@
"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

@@ -1,13 +0,0 @@
"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

@@ -1,19 +0,0 @@
"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

@@ -1,72 +0,0 @@
"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

@@ -1,79 +0,0 @@
"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

@@ -1,53 +0,0 @@
"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

@@ -1,13 +0,0 @@
"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

@@ -1,21 +0,0 @@
"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

@@ -1,13 +0,0 @@
"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

@@ -1,21 +0,0 @@
"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

@@ -1,14 +0,0 @@
"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

@@ -1,23 +0,0 @@
"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

@@ -1,21 +0,0 @@
"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

@@ -1,21 +0,0 @@
"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

@@ -1,15 +0,0 @@
"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

@@ -1,31 +0,0 @@
"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,39 +1,27 @@
"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;
});

15
app/models/marketalert.js Normal file
View File

@@ -0,0 +1,15 @@
'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;
};

View File

@@ -1,55 +0,0 @@
"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

@@ -0,0 +1,21 @@
'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,
place: DataTypes.STRING,
}, {});
RealEstateRequest.associate = function(models) {
// associations can be defined here
};
return RealEstateRequest;
};

View File

@@ -1,68 +0,0 @@
"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
},
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

@@ -1,54 +0,0 @@
"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

@@ -1,32 +0,0 @@
"use strict";
module.exports = (sequelize, DataTypes) => {
const MarketAlert = sequelize.define(
"MarketAlert",
{
url: DataTypes.STRING,
realestateOrigin: DataTypes.STRING,
originId: DataTypes.STRING,
lastDate: DataTypes.STRING,
size: DataTypes.INTEGER,
gardenSize: DataTypes.INTEGER,
price: DataTypes.INTEGER,
municipality: DataTypes.STRING,
region: DataTypes.STRING,
realEstateType: DataTypes.STRING,
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

@@ -1,32 +0,0 @@
"use strict";
module.exports = (sequelize, DataTypes) => {
const RealEstateRequest = sequelize.define(
"RealEstateRequest",
{
uniqueId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
allowNull: false
},
realEstateType: DataTypes.STRING,
email: DataTypes.STRING,
region: DataTypes.STRING,
municipality: DataTypes.STRING,
sizeMin: DataTypes.INTEGER,
sizeMax: DataTypes.INTEGER,
gardenSizeMin: DataTypes.INTEGER,
gardenSizeMax: DataTypes.INTEGER,
priceMin: DataTypes.INTEGER,
priceMax: DataTypes.INTEGER,
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.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,9 +0,0 @@
<?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.

Before

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

BIN
app/public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,122 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -1,101 +1,11 @@
@font-face {
font-family: "Alte Haas Grotesk";
src: url("./fonts/altehaasgroteskregular.ttf");
.dobrodosli-center-button {
width: 100%;
}
@font-face {
font-family: "Alte Haas Grotesk";
src: url("./fonts/altehaasgroteskbold.ttf");
font-weight: bold;
}
.welcome-center-button {
width: 100%;
}
.next-center-button {
width: 50%;
left: 25%;
}
.no-ui-slider {
width: 95%;
}
.noUi-connect {
background: #02adba;
}
.centered-element {
margin-top: 200px;
}
.centered-element-small {
margin-top: 100px;
}
.btn,
.btn-floating {
background: #02adba;
font-weight: bold;
}
.kivi-color {
color: #02adba;
}
.kivi-spinner-color {
border-color: #02adba;
}
#map {
height: 50%;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
font-family: "Alte Haas Grotesk", serif;
box-sizing: border-box;
}
#floating-panel {
top: 10px;
left: 25%;
z-index: 5;
background-color: #fff;
border: 1px solid #999;
text-align: center;
font-family: "Roboto", "sans-serif";
line-height: 30px;
padding: 5px 5px 5px 10px;
}
.btn:hover {
background-color: white;
color: #02adba;
border: 1px solid rgb(0, 173, 187);
}
.btn-floating:hover {
background-color: #02adba;
border: 1px solid rgb(0, 173, 187);
}
h6.title {
margin-top: 0;
font-weight: bold;
padding-top: 20px;
font-size: 1.3rem;
}
.locate-me-container {
margin-right: 10px;
}
h3 {
font-size: 15px;
line-height: 1.5;
.dobrodosli-big-logo {
font-size: 200pt;
background-image: url(./images/logo.png);
background-size: contain;
background-repeat: no-repeat;
color: rgba(0, 0, 0, 0);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,212 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1136.000000pt" height="1136.000000pt" viewBox="0 0 1136.000000 1136.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,1136.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M5559 11294 c-29 -7 -57 -10 -61 -8 -5 3 -8 1 -8 -5 0 -5 -13 -12
-30 -16 -16 -4 -30 -11 -30 -17 0 -6 -3 -9 -6 -5 -3 3 -27 -8 -54 -24 -26 -16
-49 -29 -52 -29 -2 0 -73 -38 -157 -85 -85 -47 -155 -85 -158 -85 -2 0 -21
-11 -43 -25 -22 -14 -40 -22 -40 -18 0 5 -4 3 -8 -2 -4 -6 -27 -20 -52 -33
-25 -13 -63 -33 -85 -46 -22 -12 -80 -44 -130 -70 -49 -27 -129 -71 -178 -97
-48 -27 -92 -49 -97 -49 -6 0 -10 -4 -10 -10 0 -5 -4 -10 -10 -10 -5 0 -71
-34 -146 -75 -75 -41 -139 -75 -141 -75 -2 0 -21 -11 -43 -25 -22 -14 -40 -22
-40 -18 0 5 -4 3 -8 -3 -6 -9 -44 -30 -277 -155 -44 -23 -81 -46 -83 -51 -2
-4 -11 -8 -21 -8 -9 0 -21 -4 -27 -9 -8 -8 -113 -66 -179 -99 -16 -9 -32 -18
-35 -22 -3 -3 -25 -16 -50 -28 -46 -22 -64 -32 -310 -167 -80 -44 -148 -80
-152 -80 -4 0 -13 -6 -20 -12 -6 -7 -17 -13 -24 -13 -7 0 -14 -3 -16 -7 -1 -5
-28 -20 -58 -36 -70 -35 -161 -86 -177 -99 -7 -6 -13 -7 -13 -2 0 5 -4 4 -8
-1 -4 -6 -34 -24 -67 -42 -33 -17 -89 -47 -125 -68 -36 -20 -82 -43 -102 -52
-21 -9 -38 -20 -38 -25 0 -4 -5 -8 -10 -8 -6 0 -42 -18 -80 -39 -39 -22 -70
-37 -70 -33 0 4 -4 2 -8 -4 -4 -5 -45 -31 -92 -56 -47 -25 -101 -54 -120 -65
-19 -11 -77 -42 -128 -69 -51 -27 -100 -53 -110 -59 -9 -5 -57 -32 -107 -59
-49 -27 -112 -61 -140 -76 -27 -15 -75 -40 -105 -56 -30 -16 -56 -32 -58 -36
-2 -5 -9 -8 -15 -8 -38 0 -175 -127 -209 -195 -11 -22 -23 -42 -27 -45 -3 -3
-8 -12 -10 -20 -2 -8 -8 -31 -13 -50 -5 -19 -11 -44 -14 -55 -6 -25 -6 -5710
0 -5745 19 -105 62 -191 132 -265 28 -30 56 -55 63 -55 6 0 11 -4 11 -9 0 -5
24 -21 53 -36 51 -27 120 -64 207 -113 25 -14 68 -37 95 -52 28 -15 82 -44
120 -65 39 -21 95 -51 125 -67 30 -16 73 -40 95 -52 22 -12 81 -44 130 -70 50
-27 115 -62 145 -79 30 -16 69 -37 85 -46 17 -9 38 -22 48 -30 9 -8 17 -10 17
-6 0 4 8 2 17 -6 10 -8 43 -28 73 -44 30 -16 69 -36 86 -46 18 -10 76 -41 130
-70 55 -29 111 -61 127 -71 15 -11 27 -16 27 -12 0 3 7 1 15 -6 8 -6 29 -20
47 -29 135 -71 214 -116 220 -125 4 -6 8 -8 8 -4 0 4 30 -10 68 -31 37 -21
108 -60 157 -86 50 -26 104 -56 120 -65 29 -16 71 -40 230 -125 100 -54 173
-94 230 -126 28 -16 61 -33 74 -39 13 -6 37 -20 52 -31 16 -11 29 -18 29 -15
0 3 26 -10 57 -29 32 -19 64 -35 71 -35 6 0 12 -4 12 -9 0 -5 9 -12 19 -16 18
-6 277 -144 381 -204 25 -14 47 -26 50 -26 5 -1 28 -14 75 -41 11 -7 43 -24
70 -38 28 -14 95 -51 150 -81 55 -31 116 -63 135 -73 19 -9 37 -19 40 -22 3
-3 30 -18 60 -34 57 -30 104 -56 225 -122 39 -21 83 -45 100 -53 16 -9 32 -18
35 -21 6 -7 53 -27 60 -27 3 0 16 -6 30 -13 89 -48 301 -39 385 16 12 8 25 11
28 7 4 -3 7 -1 7 5 0 7 6 12 14 12 7 0 19 7 26 15 7 8 21 15 30 15 10 0 20 4
22 8 2 5 41 29 88 54 47 25 121 64 165 88 44 23 101 56 128 71 26 16 54 29 62
29 8 0 15 3 15 8 0 4 31 23 70 41 38 19 70 38 70 43 0 4 8 8 19 8 10 0 24 7
31 15 7 8 16 15 21 15 5 0 85 42 178 94 94 51 172 91 175 88 4 -3 6 0 6 6 0 7
4 12 8 12 8 0 260 134 302 160 8 6 31 18 50 28 46 25 141 75 215 116 33 17 72
40 88 50 15 10 27 14 27 10 0 -5 4 -4 8 1 6 9 25 20 117 69 11 6 23 15 26 19
3 5 9 8 13 7 6 -1 40 17 241 128 33 18 76 40 95 49 19 8 37 18 40 22 3 3 23
16 45 27 176 94 231 123 308 166 48 26 92 48 97 48 6 0 10 4 10 8 0 5 17 16
38 25 20 9 64 31 97 49 33 19 96 53 140 77 44 25 100 55 125 69 25 14 68 37
95 51 28 14 52 28 55 31 3 3 23 14 45 25 22 11 85 45 141 75 55 31 116 64 135
74 46 24 130 69 364 198 39 21 81 44 95 51 14 6 27 14 30 17 3 3 34 20 70 39
136 71 224 169 271 301 l23 65 1 2877 1 2877 -22 69 c-20 61 -66 144 -106 192
-27 32 -106 89 -187 134 -46 25 -85 46 -87 46 -3 0 -22 11 -44 25 -22 14 -40
22 -40 18 0 -5 -4 -3 -8 3 -4 5 -23 18 -42 27 -19 10 -48 26 -65 36 -16 10
-70 39 -120 66 -49 26 -126 68 -170 92 -44 24 -100 54 -125 66 -25 12 -47 25
-50 28 -3 3 -18 12 -35 20 -45 23 -149 80 -161 90 -6 5 -13 7 -16 4 -3 -3 -11
1 -18 10 -7 8 -16 15 -21 15 -4 0 -30 13 -56 28 -26 16 -82 46 -123 67 -41 21
-83 45 -92 53 -10 8 -18 12 -18 8 0 -4 -17 5 -37 19 -21 14 -43 25 -49 25 -7
0 -14 3 -16 8 -1 4 -41 27 -88 51 -47 24 -97 53 -112 63 -16 11 -28 16 -28 12
0 -3 -6 -2 -12 4 -17 14 -101 60 -408 224 -41 23 -118 65 -171 94 -52 30 -101
54 -107 54 -7 0 -12 4 -12 8 0 4 -12 13 -27 19 -16 6 -59 29 -98 51 -38 21
-106 58 -150 81 -131 71 -140 76 -145 81 -3 3 -29 16 -57 29 -29 14 -53 28
-53 33 0 4 -9 8 -20 8 -11 0 -20 4 -20 8 0 4 -12 13 -27 19 -16 6 -57 28 -93
48 -36 21 -101 56 -145 80 -79 41 -146 78 -235 127 -48 27 -159 87 -225 123
-22 12 -70 38 -107 58 -65 35 -125 68 -268 145 -36 19 -78 43 -95 52 -64 38
-186 99 -220 110 -75 25 -173 30 -246 14z m176 -534 c39 -22 97 -53 130 -70
33 -17 87 -46 120 -65 33 -19 81 -45 108 -57 26 -13 47 -26 47 -30 0 -5 5 -8
11 -8 7 0 34 -13 60 -30 27 -16 49 -26 49 -22 0 4 4 3 8 -3 4 -5 32 -23 62
-39 30 -16 91 -49 135 -73 44 -24 96 -51 115 -61 19 -9 37 -20 38 -24 2 -5 7
-8 11 -8 4 0 91 -45 192 -100 101 -55 196 -106 212 -113 15 -6 27 -15 27 -19
0 -5 9 -8 20 -8 11 0 20 -4 20 -10 0 -5 4 -10 9 -10 5 0 40 -17 78 -38 103
-58 223 -123 268 -145 22 -11 42 -22 45 -26 3 -3 32 -20 65 -37 55 -28 161
-85 260 -140 36 -21 89 -49 215 -115 25 -13 47 -26 50 -29 3 -3 28 -16 55 -30
28 -14 52 -27 55 -30 3 -3 31 -18 62 -34 32 -15 60 -31 63 -37 4 -5 15 -9 26
-9 10 0 19 -3 19 -8 0 -4 30 -23 68 -41 37 -19 84 -45 105 -58 20 -13 37 -20
37 -17 0 4 8 0 18 -8 9 -9 60 -37 112 -64 52 -27 97 -52 100 -55 3 -3 39 -23
80 -44 41 -21 83 -45 93 -53 9 -8 17 -12 17 -7 0 4 6 3 13 -3 14 -12 156 -90
293 -163 54 -28 107 -59 117 -67 9 -8 17 -12 17 -8 0 4 13 -2 29 -13 16 -12
31 -21 33 -21 3 0 63 -31 133 -70 70 -38 132 -70 136 -70 4 0 9 -3 11 -7 2 -5
38 -26 80 -48 65 -33 78 -44 79 -65 0 -14 1 -1268 1 -2788 1 -2705 0 -2763
-18 -2780 -11 -9 -53 -35 -94 -56 -41 -22 -87 -48 -102 -58 -16 -11 -28 -17
-28 -14 0 4 -17 -4 -37 -17 -21 -12 -56 -31 -78 -42 -22 -11 -42 -22 -45 -25
-3 -3 -18 -12 -35 -20 -16 -8 -37 -20 -45 -26 -8 -7 -22 -14 -30 -16 -8 -2
-17 -6 -20 -9 -3 -3 -54 -32 -115 -64 -60 -32 -114 -61 -120 -65 -5 -4 -50
-29 -100 -55 -49 -26 -126 -68 -170 -92 -44 -24 -100 -54 -125 -66 -25 -12
-47 -25 -50 -28 -3 -3 -48 -28 -100 -56 -96 -51 -204 -110 -730 -396 -91 -49
-194 -106 -230 -125 -36 -19 -87 -47 -115 -62 -27 -15 -77 -42 -110 -60 -33
-18 -78 -42 -100 -53 -22 -12 -42 -24 -45 -27 -3 -3 -25 -15 -50 -28 -143 -73
-206 -108 -212 -117 -4 -5 -8 -6 -8 -1 0 5 -8 3 -17 -5 -10 -8 -31 -21 -48
-30 -16 -9 -55 -30 -85 -46 -30 -17 -80 -43 -110 -59 -30 -16 -57 -31 -60 -34
-3 -3 -23 -14 -45 -25 -22 -11 -69 -36 -105 -56 -88 -49 -164 -90 -230 -125
-30 -16 -58 -33 -62 -39 -4 -5 -8 -6 -8 -1 0 5 -6 4 -12 -2 -7 -5 -49 -29 -93
-52 -44 -24 -92 -52 -107 -62 -16 -11 -28 -16 -28 -12 0 3 -7 1 -15 -6 -8 -6
-29 -19 -47 -29 -116 -61 -237 -127 -260 -143 -14 -10 -32 -18 -40 -18 -7 0
-67 29 -132 65 -66 36 -123 65 -128 65 -4 0 -8 5 -8 12 0 6 -3 8 -7 5 -3 -4
-12 -2 -19 3 -6 6 -45 28 -85 49 -41 21 -91 48 -111 59 -21 12 -68 38 -105 58
-37 20 -136 74 -220 120 -84 46 -154 84 -157 84 -2 0 -31 15 -63 34 -32 18
-168 93 -303 166 -135 73 -270 146 -300 163 -30 16 -93 50 -140 75 -47 25 -86
49 -88 53 -2 5 -11 9 -21 9 -9 0 -21 4 -26 9 -9 8 -292 164 -500 276 -75 40
-185 99 -250 135 -27 15 -75 41 -105 57 -30 17 -90 49 -132 72 -43 23 -92 49
-110 59 -18 9 -36 22 -40 27 -4 6 -8 6 -8 2 0 -5 -12 0 -27 10 -16 10 -53 32
-83 47 -30 16 -59 32 -65 37 -5 4 -64 35 -130 69 -66 35 -123 68 -127 74 -4 6
-8 7 -8 3 0 -5 -12 0 -28 10 -15 11 -56 34 -92 53 -36 19 -83 44 -105 57 -22
12 -56 30 -75 40 -19 9 -60 31 -90 49 -30 17 -95 53 -145 79 -81 43 -143 76
-230 125 l-30 17 0 2794 0 2794 43 22 c24 12 48 26 55 31 7 5 57 33 112 62
136 72 220 117 280 150 28 15 77 42 110 60 94 51 184 100 255 139 36 20 79 43
95 51 17 8 32 17 35 20 3 3 21 13 40 24 236 125 224 118 244 135 9 7 16 10 16
6 0 -4 11 0 24 8 13 9 50 30 83 48 32 17 105 57 163 89 58 31 139 75 180 98
41 22 118 64 170 92 52 29 122 67 155 84 33 17 62 33 65 36 3 3 28 16 55 31
28 14 104 55 170 91 66 36 149 81 185 100 36 20 92 50 125 68 33 18 89 48 125
68 36 19 79 43 95 52 35 21 149 82 220 119 28 14 52 28 55 31 3 3 34 20 70 39
36 19 90 48 120 64 30 16 75 41 100 54 103 55 187 101 220 120 19 10 60 33 90
49 30 16 58 33 62 39 4 5 8 6 8 1 0 -5 6 -4 13 2 12 10 60 37 177 98 30 16 57
31 60 34 3 3 30 18 60 33 30 16 73 39 95 52 22 13 47 24 55 24 8 0 47 -17 85
-39z"/>
<path d="M5614 10238 c-3 -5 -11 -8 -17 -7 -7 1 -25 -3 -40 -9 -16 -7 -33 -12
-38 -12 -5 0 -9 -4 -9 -9 0 -5 -8 -11 -17 -15 -10 -3 -49 -24 -88 -46 -38 -22
-95 -52 -125 -67 -30 -16 -59 -32 -65 -36 -13 -10 -316 -177 -322 -177 -2 0
-20 -11 -40 -25 -20 -13 -40 -22 -45 -19 -4 3 -8 1 -8 -5 0 -5 -9 -13 -20 -16
-12 -4 -58 -29 -104 -56 -46 -27 -86 -49 -89 -49 -3 0 -48 -25 -101 -55 -52
-30 -97 -55 -99 -55 -3 0 -51 -26 -108 -58 -57 -33 -134 -75 -171 -95 -173
-94 -205 -112 -240 -134 -21 -13 -38 -20 -38 -16 0 5 -4 4 -8 -2 -8 -11 -16
-16 -147 -86 -52 -28 -97 -55 -101 -60 -3 -5 -14 -9 -25 -9 -10 0 -19 -4 -19
-9 0 -5 -8 -11 -17 -14 -16 -5 -72 -35 -208 -111 -27 -16 -108 -60 -180 -100
-71 -39 -131 -74 -133 -78 -2 -5 -12 -8 -22 -8 -10 0 -20 -3 -22 -7 -1 -5 -32
-23 -68 -42 -36 -19 -78 -43 -95 -53 -16 -10 -37 -21 -45 -25 -13 -6 -205
-111 -310 -170 -19 -11 -60 -33 -90 -49 -30 -16 -58 -33 -62 -39 -4 -5 -8 -6
-8 -2 0 4 -17 -4 -37 -18 -21 -14 -41 -25 -46 -25 -4 0 -24 -10 -45 -23 -20
-13 -59 -31 -85 -41 -27 -10 -60 -29 -74 -42 -14 -13 -30 -24 -34 -24 -5 0 -9
-7 -9 -15 0 -8 -4 -15 -8 -15 -5 0 -19 -21 -33 -47 l-24 -48 -2 -2384 c-2
-1741 0 -2388 8 -2398 6 -7 9 -13 5 -13 -4 0 0 -13 8 -28 23 -45 90 -105 153
-137 32 -16 99 -52 148 -80 50 -28 108 -59 130 -70 22 -11 42 -23 45 -26 3 -3
43 -26 90 -51 47 -25 105 -56 130 -70 97 -53 367 -201 395 -217 17 -9 32 -18
35 -21 3 -3 21 -13 40 -22 36 -18 163 -87 235 -128 22 -13 74 -41 115 -63 41
-22 80 -43 85 -47 6 -4 51 -29 100 -56 94 -50 127 -68 269 -146 47 -27 90 -48
96 -48 5 0 10 -3 10 -8 0 -4 17 -15 38 -24 20 -10 62 -32 92 -49 30 -17 84
-47 120 -67 36 -19 81 -44 100 -55 19 -11 62 -35 95 -52 33 -18 85 -47 115
-64 30 -17 71 -39 90 -49 19 -9 37 -19 40 -22 3 -3 21 -13 40 -22 19 -9 67
-35 105 -57 39 -23 93 -52 120 -66 28 -15 64 -34 80 -44 17 -10 62 -35 100
-56 39 -21 105 -58 149 -82 43 -23 82 -43 86 -43 5 0 10 -3 12 -7 2 -5 23 -19
48 -31 25 -13 79 -42 120 -65 65 -35 83 -40 136 -40 107 0 200 65 235 164 16
43 17 359 17 4088 0 2223 2 4044 4 4046 2 2 32 -14 66 -35 35 -22 65 -40 68
-40 3 0 32 -18 64 -40 32 -22 64 -40 69 -40 6 0 11 -4 11 -9 0 -5 8 -11 18
-14 9 -3 60 -33 112 -67 52 -34 103 -64 113 -67 9 -3 17 -9 17 -14 0 -5 6 -9
14 -9 8 0 16 -3 18 -7 2 -5 15 -15 30 -23 73 -40 223 -135 226 -142 2 -4 8 -8
13 -8 4 0 58 -31 120 -70 61 -38 113 -70 114 -70 2 0 24 -13 48 -30 25 -16 50
-30 56 -30 6 0 11 -4 11 -10 0 -5 7 -10 15 -10 8 0 15 -4 15 -9 0 -5 8 -11 18
-14 18 -6 73 -38 82 -48 3 -3 46 -29 95 -59 50 -30 92 -56 95 -60 3 -3 21 -13
40 -23 270 -125 536 135 378 370 -33 48 -74 81 -165 129 -18 10 -33 21 -33 26
0 4 -6 8 -12 8 -7 0 -44 21 -83 46 -38 25 -81 52 -95 60 -14 8 -38 23 -55 34
-16 11 -37 23 -45 27 -17 8 -92 55 -100 63 -10 10 -65 41 -82 47 -10 3 -18 9
-18 14 0 5 -7 9 -15 9 -8 0 -15 5 -15 10 0 6 -5 10 -12 10 -7 0 -28 11 -47 24
-20 13 -126 78 -236 146 -110 67 -216 133 -235 145 -83 53 -138 86 -175 106
-22 12 -42 24 -45 28 -6 7 -37 27 -65 41 -11 6 -32 18 -47 28 -121 77 -262
162 -269 162 -5 0 -9 3 -9 8 0 4 -10 12 -23 18 -12 6 -53 30 -91 54 -38 23
-74 43 -80 45 -6 1 -24 7 -41 14 -37 15 -124 21 -131 9z m-203 -695 c4 -186 0
-3133 -5 -3134 -4 0 -22 9 -42 20 -19 11 -37 20 -41 20 -3 0 -9 4 -12 9 -3 5
-25 20 -48 33 -24 13 -114 67 -200 119 -159 96 -219 132 -283 170 -52 31 -100
59 -192 115 -46 28 -87 52 -93 54 -5 2 -20 13 -33 25 -13 11 -26 20 -28 18 -5
-3 -95 49 -181 104 -28 19 -56 34 -62 34 -6 0 -11 5 -11 10 0 6 -6 10 -14 10
-7 0 -19 7 -26 15 -7 8 -16 12 -21 9 -5 -3 -9 -1 -9 4 0 6 -19 19 -42 31 -24
12 -113 64 -198 117 -85 52 -165 99 -177 104 -13 5 -23 15 -23 21 0 7 -3 10
-6 6 -3 -3 -41 15 -83 42 -42 26 -83 50 -91 54 -8 4 -27 15 -42 25 -14 9 -55
34 -90 55 -34 20 -70 42 -78 47 -64 40 -152 90 -160 90 -6 0 -10 4 -10 8 0 5
-17 17 -37 29 -83 45 -119 66 -135 81 -10 8 -18 11 -18 6 0 -5 -4 -4 -8 2 -8
12 -87 61 -114 70 -10 4 -18 10 -18 15 0 5 -4 9 -10 9 -14 0 -110 60 -110 70
0 4 15 13 33 20 17 8 39 19 47 25 8 7 58 34 110 61 52 27 97 51 100 54 3 3 21
13 40 24 51 27 150 81 278 153 63 35 115 63 117 63 2 0 30 15 62 34 70 40 160
90 228 125 28 14 52 29 53 34 2 4 8 7 13 7 5 0 45 20 87 44 43 24 118 66 168
92 49 26 92 52 96 58 4 6 8 8 8 4 0 -4 27 8 59 27 33 19 64 35 69 35 6 0 12 3
14 8 2 4 25 18 51 32 44 23 59 31 127 70 14 8 28 13 33 12 4 -1 7 3 7 8 0 6 4
10 9 10 5 0 51 24 102 53 52 29 112 61 134 72 22 11 42 22 45 25 3 3 14 10 25
16 358 195 584 320 600 330 28 20 35 17 36 -13z m-3000 -1923 c5 0 15 -5 22
-10 15 -14 257 -160 263 -160 3 0 32 -18 65 -40 32 -23 59 -38 59 -34 0 4 8 0
18 -8 22 -19 217 -138 226 -138 9 0 70 -43 74 -52 2 -5 12 -8 22 -8 10 0 20
-3 22 -7 2 -7 106 -70 298 -183 8 -5 193 -116 410 -247 217 -131 404 -243 416
-249 12 -6 39 -23 60 -38 21 -14 44 -26 50 -26 7 0 14 -3 16 -7 4 -10 375
-233 387 -233 5 0 11 -3 13 -7 2 -5 59 -42 128 -83 69 -41 128 -79 131 -83 3
-5 9 -8 13 -7 4 1 20 -7 37 -18 l30 -19 -86 -59 c-47 -32 -93 -63 -103 -69 -9
-5 -19 -12 -22 -15 -3 -3 -13 -9 -22 -15 -9 -5 -82 -55 -162 -110 -80 -55
-154 -104 -166 -110 -11 -5 -20 -13 -20 -17 0 -5 -7 -8 -15 -8 -8 0 -15 -4
-15 -10 0 -5 -7 -10 -15 -10 -8 0 -15 -4 -15 -8 0 -4 -15 -16 -32 -26 -18 -11
-50 -32 -70 -48 -21 -17 -38 -26 -38 -21 0 4 -5 0 -11 -9 -5 -10 -16 -18 -23
-18 -7 0 -21 -8 -32 -17 -18 -17 -255 -180 -309 -213 -68 -42 -330 -224 -333
-231 -2 -5 -8 -9 -13 -9 -8 0 -184 -115 -199 -130 -3 -3 -66 -45 -140 -95 -74
-49 -137 -92 -140 -95 -3 -3 -30 -21 -60 -40 -30 -19 -57 -37 -60 -40 -3 -3
-59 -42 -125 -85 -66 -44 -121 -83 -123 -87 -2 -5 -10 -8 -17 -8 -7 0 -15 -3
-17 -7 -1 -5 -86 -64 -188 -132 -102 -69 -187 -128 -188 -133 -2 -4 -8 -6 -13
-2 -5 3 -9 0 -9 -5 0 -6 -6 -11 -13 -11 -10 0 -12 348 -11 1788 1 983 2 1788
3 1790 0 2 8 -4 17 -12 8 -9 20 -16 25 -16z m3001 -3902 c0 -986 -1 -1803 -1
-1815 -1 -26 -13 -29 -33 -10 -7 6 -31 21 -53 32 -43 21 -260 140 -285 155 -8
6 -26 15 -39 20 -13 6 -36 19 -51 30 -15 11 -29 17 -32 14 -3 -2 -11 2 -18 11
-7 8 -16 15 -20 15 -4 0 -52 25 -106 56 -55 31 -128 72 -164 90 -36 19 -72 39
-80 44 -27 18 -271 150 -276 150 -2 0 -21 10 -42 23 -20 13 -77 45 -127 72
-49 26 -94 51 -100 55 -5 4 -50 29 -100 55 -49 27 -103 56 -120 66 -16 10 -55
30 -85 46 -30 15 -57 30 -60 33 -3 4 -21 14 -40 24 -19 9 -62 32 -95 50 -33
19 -91 50 -130 71 -38 21 -99 55 -135 75 -36 21 -110 61 -165 90 -55 29 -104
56 -110 60 -5 4 -36 21 -67 39 -189 102 -200 109 -345 190 l-92 52 182 122
c100 67 184 125 185 129 2 4 8 8 13 8 6 0 15 5 22 11 24 21 439 299 447 299 4
0 10 7 14 15 3 8 12 15 19 15 8 0 22 9 32 20 10 11 20 18 23 16 2 -3 10 0 17
7 27 26 55 47 55 41 0 -3 17 7 38 23 20 16 57 42 82 58 25 17 51 36 58 43 7 6
20 12 28 12 8 0 14 4 14 9 0 5 21 22 47 37 26 15 56 36 67 46 11 10 25 18 32
18 7 0 14 4 16 8 2 5 64 49 138 99 198 133 374 251 385 260 6 4 87 60 180 122
259 175 260 175 263 184 2 4 8 7 12 7 5 0 29 14 52 30 102 72 129 90 134 90 3
0 10 5 17 10 30 26 137 100 137 94 0 -3 6 1 13 8 8 7 35 26 60 42 26 17 47 34
47 38 0 4 4 8 10 8 5 0 35 18 67 40 31 21 59 37 61 35 3 -2 4 -811 4 -1797z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,19 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -1,47 +0,0 @@
"use strict";
const express = require("express");
const welcome = require("../controllers/welcome").getWelcome;
const {
getRealEstateTypes,
postRealEstateTypes
} = require("../controllers/realEstateTypes");
const {
getQueryReview,
postQueryReview
} = require("../controllers/queryReview");
const { getGoAgain } = require("../controllers/goAgain");
const { getLocation, postLocation } = require("../controllers/location");
const { getUnsubscribe } = require("../controllers/unsubscribe");
const { getRealEstates } = require("../controllers/realEstates");
const { getRedirect } = require("../controllers/redirect");
const { getFilters, postFilters } = require("../controllers/realEstateFilters");
const router = express.Router();
router.get("/", welcome);
router.get("/vrstanekretnine/:searchRequestId", getRealEstateTypes);
router.get("/vrstanekretnine", getRealEstateTypes);
router.post("/vrstanekretnine/:searchRequestId", postRealEstateTypes);
router.post("/vrstanekretnine", postRealEstateTypes);
router.get("/lokacija/:searchRequestId", getLocation);
router.post("/lokacija/:searchRequestId", postLocation);
router.get("/filteri/:searchRequestId", getFilters);
router.post("/filteri/:searchRequestId", postFilters);
router.get("/pregled/:searchRequestId", getQueryReview);
router.post("/pregled/:searchRequestId", postQueryReview);
router.get("/odjava/:searchRequestId", getUnsubscribe);
router.get("/ponovo", getGoAgain);
router.get("/nekretnine/:searchRequestId", getRealEstates);
router.get("/redirect/:id", getRedirect);
module.exports = router;

View File

@@ -1,56 +0,0 @@
"use strict";
let AWS = require("aws-sdk");
const htmlToText = require("html-to-text");
const { AWS_EMAIL_CONFIG } = require("../config/appConfig");
AWS.config.update({
region: AWS_EMAIL_CONFIG.REGION,
credentials: {
accessKeyId: AWS_EMAIL_CONFIG.CREDENTIALS.ACCESS_KEY_ID,
secretAccessKey: AWS_EMAIL_CONFIG.CREDENTIALS.SECRET_ACCESS_KEY
}
});
const awsMailer = new AWS.SES({ apiVersion: "2010-12-01" });
const sendEmail = async (to, subject, message, from) => {
const params = {
Destination: {
ToAddresses: [to]
},
Message: {
Subject: {
Charset: "UTF-8",
Data: subject
},
Body: {
Html: {
Charset: "UTF-8",
Data: message
},
Text: {
Charset: "UTF-8",
Data: htmlToText.fromString(message)
}
}
},
ReturnPath: from ? from : AWS_EMAIL_CONFIG.SOURCE_EMAIL,
Source: from ? from : AWS_EMAIL_CONFIG.SOURCE_EMAIL
};
return new Promise((resolve, reject) => {
awsMailer.sendEmail(params, (error, data) => {
if (error) {
reject(error);
} else {
resolve(data);
}
});
});
};
module.exports = {
sendEmail
};

View File

@@ -1,62 +0,0 @@
"use strict";
const {
matchRealEstates,
matchSearchRequest
} = require("../services/searchMatchService");
const {
generateNotificationEmail,
generateNewSearchRequestEmail,
generateEmailSubject
} = require("../helpers/emailContentGenerator");
const { sendEmail } = require("../services/emailService");
const notifyForNewRealEstates = async newRealEstates => {
const matches = await matchRealEstates(newRealEstates);
await notifyMatches(matches);
};
const notifyForNewSearchRequest = async searchRequest => {
const matches = await matchSearchRequest(searchRequest);
const searchRequestId = searchRequest.id;
const matchingRealEstates = matches[searchRequestId].realEstates;
const emailContent = generateNewSearchRequestEmail(
searchRequest,
matchingRealEstates
);
const { email } = searchRequest;
await sendEmail(email, "Kivi - novi zahtjev za pretragu", emailContent);
};
const notifyMatches = async matches => {
const searchRequestsToNotify = Object.keys(matches);
const asyncSendEmailActions = [];
for (const id of searchRequestsToNotify) {
const { searchRequest } = matches[id];
const { email } = searchRequest;
const allMatchingRealEstates = matches[id].realEstates || [];
if (allMatchingRealEstates.length > 0) {
const emailContent = generateNotificationEmail(
allMatchingRealEstates,
id
);
const emailSubject = generateEmailSubject(
allMatchingRealEstates.length,
allMatchingRealEstates[0].title
);
const sendEmailPromise = sendEmail(email, emailSubject, emailContent);
asyncSendEmailActions.push(sendEmailPromise);
sendEmailPromise.catch(err => console.log("[Email Sending Failed]", err));
}
}
await Promise.all(asyncSendEmailActions);
};
module.exports = {
notifyForNewRealEstates,
notifyForNewSearchRequest
};

View File

@@ -1,76 +0,0 @@
"use strict";
const {
findSearchRequestsForRealEstate
} = require("../helpers/db/searchRequest");
const { findRealEstatesForSearchRequest } = require("../helpers/db/realEstate");
const { addMatches } = require("../helpers/db/searchRequestMatch");
const { MAX_REAL_ESTATES_IN_FIRST_EMAIL } = require("../config/appConfig");
const matchRealEstates = async realEstates => {
if (Array.isArray(realEstates)) {
const asyncMatchActions = [];
const matches = {};
const matchingRecords = [];
for (const realEstate of realEstates) {
const searchRequestsPromise = findSearchRequestsForRealEstate(realEstate);
asyncMatchActions.push(searchRequestsPromise);
searchRequestsPromise.then(searchRequests => {
for (const searchRequest of searchRequests) {
const { id } = searchRequest;
if (!matches[id]) {
matches[id] = {
searchRequest,
realEstates: []
};
}
matches[id].realEstates.push(realEstate);
matchingRecords.push({
searchRequestId: searchRequest.id,
realEstateId: realEstate.id,
notified: false
});
}
});
}
await Promise.all(asyncMatchActions);
await addMatches(matchingRecords);
return matches;
}
};
const matchSearchRequest = async searchRequest => {
const { id: searchRequestId } = searchRequest;
const realEstates = await findRealEstatesForSearchRequest(
searchRequest,
MAX_REAL_ESTATES_IN_FIRST_EMAIL
);
const matches = {
[searchRequestId]: {
searchRequest,
realEstates: []
}
};
const matchingRecords = [];
for (const realEstate of realEstates) {
matches[searchRequestId].realEstates.push(realEstate);
matchingRecords.push({
searchRequestId,
realEstateId: realEstate.id,
notified: false
});
}
await addMatches(matchingRecords);
return matches;
};
module.exports = {
matchRealEstates,
matchSearchRequest
};

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