diff --git a/app/controllers/realEstates.js b/app/controllers/realEstates.js index 1533159..24c05de 100644 --- a/app/controllers/realEstates.js +++ b/app/controllers/realEstates.js @@ -1,11 +1,8 @@ const { allMarketAlertsByRequest } = require("../helpers/db/dbHelper"); const getRealEstates = async (req, res) => { - console.log("Enter get realestates"); const request = req.params["request_id"]; - console.log(req.params["request_id"]); const realEstates = await allMarketAlertsByRequest(request); - console.log(realEstates); const title = "Ovo su nekretnine koje smo pronašli za vas"; res.render("realEstates", { realEstates, title }); diff --git a/app/helpers/awsEmail.js b/app/helpers/awsEmail.js index 6c3edef..9466698 100644 --- a/app/helpers/awsEmail.js +++ b/app/helpers/awsEmail.js @@ -198,8 +198,6 @@ const sendBulkEmail = async marketAlerts => { ReplacementTemplateData: repData }); } - console.log("AWS EMAIL : Bulk email replacement data:"); - console.log(destinations); var params = { Destinations: destinations, @@ -214,8 +212,6 @@ const sendBulkEmail = async marketAlerts => { .sendBulkTemplatedEmail(params) .promise(); const awsResult = await sendPromise; - console.log("AWS SES bulk email response"); - console.log(awsResult); } catch (e) { console.log("Could not send bulk email", e); } diff --git a/app/helpers/crawlers/olxClawler.js b/app/helpers/crawlers/olxClawler.js index 139b30b..90e1543 100644 --- a/app/helpers/crawlers/olxClawler.js +++ b/app/helpers/crawlers/olxClawler.js @@ -1,375 +1,364 @@ -const fetch = require('node-fetch'); -const cheerio = require('cheerio'); -const { allRERequest, findPointInsideBoundingBox } = require('../db/dbHelper'); -const { getRealEstateTypeEnum } = require('../enums'); -const { getRegion, getMunicipality } = require('../codes') +const fetch = require("node-fetch"); +const cheerio = require("cheerio"); +const { allRERequest, findPointInsideBoundingBox } = require("../db/dbHelper"); +const { getRealEstateTypeEnum } = require("../enums"); +const { getRegion, getMunicipality } = require("../codes"); const Promise = require("bluebird"); module.exports = class OlxCrawler { - //TODO figure best way to handle paging - constructor(hrefs = []) { - this.hrefs = hrefs; - } + //TODO figure best way to handle paging + constructor(hrefs = []) { + this.hrefs = hrefs; + } - async indexPages(urls) { - const indexers = []; + async indexPages(urls) { + const indexers = []; - urls.forEach(url => { - indexers.push(new Indexer(url)); - }); + urls.forEach(url => { + indexers.push(new Indexer(url)); + }); - return Promise.map(indexers, function (indexer) { - return indexer.indexWithPagination(); - }).then(async (results) => { - return results - }) - } + return Promise.map(indexers, function(indexer) { + return indexer.indexWithPagination(); + }).then(async results => { + return results; + }); + } - async crawl() { - console.log("OLX CRAWLER: start crawl"); + async crawl() { + const filteredResults = []; + const realestateRequests = await allRERequest(); + const urls = this.createRequestUrls(realestateRequests); + let results = await this.indexPages( + urls, + this.fromPage, + this.toPage, + this.maxResults + ); + const flatResults = results.flat(); + if (flatResults) { + for (const finalResult of flatResults) { + if (null !== finalResult) { + if ( + finalResult.lat !== undefined && + finalResult.lat !== null && + finalResult.lat !== "" + ) { + const pointInsideBoundingBox = await findPointInsideBoundingBox( + [finalResult.lng, finalResult.lat], + finalResult.email, + finalResult.uuid + ); - const filteredResults = []; - const realestateRequests = await allRERequest(); - console.log("OLX CRAWLER: found " + realestateRequests.length + "subscribed RealEstateRequests"); - const urls = this.createRequestUrls(realestateRequests); - let results = await this.indexPages(urls, this.fromPage, this.toPage, this.maxResults); - console.log("Final crawler results"); - const flatResults = results.flat(); - console.log(flatResults); - if (flatResults) { - console.log(flatResults.length); - - for (const finalResult of flatResults) { - - if (null !== finalResult) { - if (finalResult.lat !== undefined && finalResult.lat !== null && finalResult.lat !== "") { - const pointInsideBoundingBox = await findPointInsideBoundingBox([finalResult.lng, finalResult.lat], finalResult.email, finalResult.uuid); - - - if (pointInsideBoundingBox[0].length !== 0) { - finalResult.hasLocation = true - filteredResults.push(finalResult); - } else { - finalResult.hasLocation = false - filteredResults.push(finalResult); - } - } - } + if (pointInsideBoundingBox[0].length !== 0) { + finalResult.hasLocation = true; + filteredResults.push(finalResult); + } else { + finalResult.hasLocation = false; + filteredResults.push(finalResult); } - - console.log("OLX CRAWLER: number of olx crawler results, after geo location filtering: " + filteredResults.length); - return filteredResults; + } } - return [] + } + return filteredResults; + } + return []; + } + + createRequestUrls(realestateRequests) { + const urls = []; + + for (const request of realestateRequests) { + const realsestateType = + "kategorija=" + + getRealEstateTypeEnum(request.realEstateType).olxCategory; + const region = "kanton=" + getRegion(request.region).olxid; + const municipality = + "grad%5B%5D=" + + getMunicipality(request.region, request.municipality).olxid; + const sizeMin = "kvadrata_min=" + request.sizeMin; + const sizeMax = "kvadrata_max=" + request.sizeMax; + const priceMin = "od=" + request.priceMin; + const priceMax = "do=" + request.priceMax; + + const olxUrl = { + url: `https://www.olx.ba/pretraga?${realsestateType}&id=2&stanje=0&vrstapregleda=tabela&sort_order=desc&${region}&${municipality}&${priceMin}&${priceMax}&vrsta=samoprodaja&${sizeMin}&${sizeMax}&stranica=`, + email: request.email, + uuid: request.uniqueId, + hrefs: this.hrefs + }; + urls.push(olxUrl); } - createRequestUrls(realestateRequests) { - const urls = [] - - for (const request of realestateRequests) { - const realsestateType = "kategorija=" + getRealEstateTypeEnum(request.realEstateType).olxCategory; - const region = "kanton=" + getRegion(request.region).olxid; - const municipality = "grad%5B%5D=" + getMunicipality(request.region, request.municipality).olxid; - const sizeMin = "kvadrata_min=" + request.sizeMin; - const sizeMax = "kvadrata_max=" + request.sizeMax; - const priceMin = "od=" + request.priceMin; - const priceMax = "do=" + request.priceMax; - - const olxUrl = { - url: `https://www.olx.ba/pretraga?${realsestateType}&id=2&stanje=0&vrstapregleda=tabela&sort_order=desc&${region}&${municipality}&${priceMin}&${priceMax}&vrsta=samoprodaja&${sizeMin}&${sizeMax}&stranica=`, - email: request.email, - uuid: request.uniqueId, - hrefs: this.hrefs - } - console.log(olxUrl.url); - urls.push(olxUrl); - } - - return urls; - } + return urls; + } }; - class Indexer { + /** + * + * @param {String|Array} olxUrl single or array of objects containing url email and uuid + * @param {Array} hrefResutls array contaning urls from crawler results + */ - /** - * - * @param {String|Array} olxUrl single or array of objects containing url email and uuid - * @param {Array} hrefResutls array contaning urls from crawler results - */ + constructor(olxUrl, hrefResutls) { + this.olxUrl = olxUrl; + this.hrefResutls = hrefResutls; + } - constructor(olxUrl, hrefResutls) { - this.olxUrl = olxUrl; - this.hrefResutls = hrefResutls; - } + async indexWithPagination(pageNumber = 1) { + const pageNr = this.olxUrl.url.match(/\d+$/); + const indexers = this.prepareIndexers(pageNumber ? [pageNumber] : pageNr); - async indexWithPagination(pageNumber = 1) { + try { + return Promise.map(indexers.indexers, function(indexer) { + return indexer.indexPage(pageNumber); + }).then(async results => { + let hasResults = false; - console.log("This is olxUrl:" + this.olxUrl.url); - const pageNr = this.olxUrl.url.match(/\d+$/); - const indexers = this.prepareIndexers(pageNumber ? [pageNumber] : pageNr); + results.forEach(result => { + if (!hasResults) { + hasResults = result.hasResults; + } + }); - try { + if (!hasResults) { + const singlePageIndexers = this.prepareHrefIndexers(results); + if (singlePageIndexers.length === 0) { + return []; + } - return Promise.map(indexers.indexers, function (indexer) { - return indexer.indexPage(pageNumber); - }).then(async (results) => { - let hasResults = false; - - results.forEach(result => { - if (!hasResults) { - console.log("No results detected") - hasResults = result.hasResults - } - }); - - if (!hasResults) { - console.log("HAS NO MORE RESULTS, stop the paging, there are some results and they should contain only HREFS"); - console.log(results.length); - const singlePageIndexers = this.prepareHrefIndexers(results); - if (singlePageIndexers.length === 0) { - console.log("THERE IS NOT EVEN SINGLE RESULT"); - return [] - } - - return Promise.map(singlePageIndexers, function (indexer) { - return indexer.indexSingle(); - }).then(async (results) => { - console.log("SinglePageMethod in HAS NO RESULTS, MarketAralms"); - console.log(results.length); - return results; - }); - - } else { - console.log("HAS MORE RESULTS, should only contain HREFS"); - console.log(results.length); - const newResults = await this.indexWithPagination(results[0].pageNumber + 5); - const singlePageIndexers = this.prepareHrefIndexers(results); - - const newerResults = await Promise.map(singlePageIndexers, function (indexer) { - return indexer.indexSingle(); - }).then(async (results) => { - console.log("SinglePageMethod HAS RESULTS, should contain MarketAlerts only"); - console.log(results.length); - return results; - }); - - Array.prototype.push.apply(newResults, newerResults); - return newResults; - - } - }); - } catch (e) { - console.error("Error has accured", e); - } - - } - - prepareIndexers(pageNr) { - - console.log("Entering prepareIndexers : page nr - " + pageNr); - const indexers = []; - let lastPageNumber; - if (pageNr) { - for (let index = Number(pageNr[0]); index <= Number(pageNr[0]) + 5; index++) { - lastPageNumber = index; - const newOlxUrl = { - url: this.olxUrl.url.replace(/\d+$/, "") + index, - email: this.olxUrl.email, - uuid: this.olxUrl.uuid, - hrefs: this.olxUrl.hrefs - } - indexers.push(new Indexer(newOlxUrl)); - - } + return Promise.map(singlePageIndexers, function(indexer) { + return indexer.indexSingle(); + }).then(async results => { + return results; + }); } else { - for (let index = 1; index <= 5; index++) { - lastPageNumber = index; - const newOlxUrl = { - url: this.olxUrl.url + index, - email: this.olxUrl.email, - uuid: this.olxUrl.uuid, - hrefs: this.olxUrl.hrefs - } - indexers.push(new Indexer(newOlxUrl)); - } + const newResults = await this.indexWithPagination( + results[0].pageNumber + 5 + ); + const singlePageIndexers = this.prepareHrefIndexers(results); + + const newerResults = await Promise.map(singlePageIndexers, function( + indexer + ) { + return indexer.indexSingle(); + }).then(async results => { + return results; + }); + + Array.prototype.push.apply(newResults, newerResults); + return newResults; } - return { - indexers: indexers, - lastPageNumber: lastPageNumber + }); + } catch (e) { + console.error("Error has accured", e); + } + } + + prepareIndexers(pageNr) { + const indexers = []; + let lastPageNumber; + if (pageNr) { + for ( + let index = Number(pageNr[0]); + index <= Number(pageNr[0]) + 5; + index++ + ) { + lastPageNumber = index; + const newOlxUrl = { + url: this.olxUrl.url.replace(/\d+$/, "") + index, + email: this.olxUrl.email, + uuid: this.olxUrl.uuid, + hrefs: this.olxUrl.hrefs }; + indexers.push(new Indexer(newOlxUrl)); + } + } else { + for (let index = 1; index <= 5; index++) { + lastPageNumber = index; + const newOlxUrl = { + url: this.olxUrl.url + index, + email: this.olxUrl.email, + uuid: this.olxUrl.uuid, + hrefs: this.olxUrl.hrefs + }; + indexers.push(new Indexer(newOlxUrl)); + } } + return { + indexers: indexers, + lastPageNumber: lastPageNumber + }; + } - prepareHrefIndexers(results) { - const indexers = [] + prepareHrefIndexers(results) { + const indexers = []; - if (!Array.isArray(results)) { - results.hrefs.forEach(href => { - const newOlxUrl = { - url: href, - email: results.olxUrl.email, - uuid: results.olxUrl.uuid, - hrefs: this.olxUrl.hrefs - } + if (!Array.isArray(results)) { + results.hrefs.forEach(href => { + const newOlxUrl = { + url: href, + email: results.olxUrl.email, + uuid: results.olxUrl.uuid, + hrefs: this.olxUrl.hrefs + }; - indexers.push(new Indexer(newOlxUrl)); - }); - - } else { - - - results.forEach(result => { - - if (result !== null && result.hasOwnProperty('hrefs')) { - result.hrefs.forEach(href => { - const newOlxUrl = { - url: href, - email: result.olxUrl.email, - uuid: result.olxUrl.uuid, - hrefs: this.olxUrl.hrefs - } - - indexers.push(new Indexer(newOlxUrl)); - }) - } - - }); - } - - return indexers; - } - - async indexPage(pageNumber) { - console.log("Page number in index page, max page number :") - console.log(pageNumber); - - try { - - console.log("Indexing page: " + this.olxUrl.url); - const res = await fetch(this.olxUrl.url); - const body = await res.text(); - const $ = cheerio.load(body); - const hrefs = []; - let hasResults = false - - $('#rezultatipretrage').find('.listitem').each((i, elem) => { - hasResults = true - const href = $(elem).find('a').first().attr('href'); - hrefs.push(href); - }); - - console.log("this is hrefs for olxUrl" + this.olxUrl.url); - console.log("NUMBER OF HREFS " + hrefs.length); - - return { - hrefs: hrefs, - hasResults: hasResults, - pageNumber: pageNumber, - olxUrl: this.olxUrl - } - } catch (e) { - console.error('Exception caught:' + e); - } - } - - async indexSingle() { - try { - console.log("Index single"); - console.log(this.olxUrl.url); - - if (this.olxUrl.url === undefined) { - return {} - } - - // if (global.hrefs) { - - if (this.olxUrl.hrefs[this.olxUrl.uuid] && this.olxUrl.hrefs[this.olxUrl.uuid].includes(this.olxUrl.url)) { - - console.log("We found duplicate URL"); - return null - } - // } - - const res = await fetch(this.olxUrl.url); - const body = await res.text(); - const $ = cheerio.load(body); - - const title = $('#naslovartikla').text().trim(); - const realEstateType = $('#artikal_glavni_div > div.artikal_lijevo > div:nth-child(3) > div > span:nth-child(3) > a > span').text(); - - const price = $('#pc > p:nth-child(2)').text(); - const size = $('#dodatnapolja1 > div:nth-child(1) > div.df2').text(); - const rooms = $('#dodatnapolja1 > div:nth-child(2) > div.df2').text(); - const address = $('#dodatnapolja1 > div:nth-child(5) > div.df2').text(); - const gardenSize = $('#dodatnapolja1 > div:nth-child(6) > div.df2').text(); - const location = $('#artikal_glavni_div > div.artikal_lijevo > div.op.pop.mobile-lokacija').attr('data-content'); - - const time = $('time').attr('datetime'); - const olxId = $('#artikal_glavni_div > div.artikal_lijevo > div:nth-child(15) > div:nth-child(4) > div.df2').text(); - - const descriptions = $('.artikal_detaljniopis_tekst'); - const latLngRe = /LatLng\(([0-9]+\.[0-9]+)\,\s+([0-9]+\.[0-9]+)\)/g; - const imgRe = /href":("[^"]*")/g; - const matches = latLngRe.exec(body); - let lng = '', - lat = ''; - const parsePrice = (price) => parseFloat(price.replace(".", "")) - - if (matches && matches.length >= 3) { - lat = matches[1]; - lng = matches[2]; - } - - const parsedPrice = parsePrice(price); - - const locationArray = location.split(","); - const region = locationArray[0]; - const municipality = locationArray[1]; - - const data = { - realEstateType: this.getCategoryId(realEstateType), - email: this.olxUrl.email, - uuid: this.olxUrl.uuid, - olxId: olxId, - url: this.olxUrl.url, - title, - price: isNaN(parsedPrice) ? 0 : parsedPrice, - size: parseFloat(size), - gardenSize: isNaN(parseFloat(gardenSize)) ? 0 : parseFloat(gardenSize), - address, - region, - municipality, - time, - shortDescription: descriptions.first().text(), - longDescription: descriptions.last().text(), - lat, - lng, - loc: [parseFloat(lat), parseFloat(lng)], + indexers.push(new Indexer(newOlxUrl)); + }); + } else { + results.forEach(result => { + if (result !== null && result.hasOwnProperty("hrefs")) { + result.hrefs.forEach(href => { + const newOlxUrl = { + url: href, + email: result.olxUrl.email, + uuid: result.olxUrl.uuid, + hrefs: this.olxUrl.hrefs }; - return data; - } catch (e) { - console.error('Exception caught: ' + e.message); + indexers.push(new Indexer(newOlxUrl)); + }); } + }); + } + return indexers; + } + + async indexPage(pageNumber) { + try { + const res = await fetch(this.olxUrl.url); + const body = await res.text(); + const $ = cheerio.load(body); + const hrefs = []; + let hasResults = false; + + $("#rezultatipretrage") + .find(".listitem") + .each((i, elem) => { + hasResults = true; + const href = $(elem) + .find("a") + .first() + .attr("href"); + hrefs.push(href); + }); + return { + hrefs: hrefs, + hasResults: hasResults, + pageNumber: pageNumber, + olxUrl: this.olxUrl + }; + } catch (e) { + console.error("Exception caught:" + e); + } + } + + async indexSingle() { + try { + if (this.olxUrl.url === undefined) { + return {}; + } + + // if (global.hrefs) { + + if ( + this.olxUrl.hrefs[this.olxUrl.uuid] && + this.olxUrl.hrefs[this.olxUrl.uuid].includes(this.olxUrl.url) + ) { return null; + } + // } + + const res = await fetch(this.olxUrl.url); + const body = await res.text(); + const $ = cheerio.load(body); + + const title = $("#naslovartikla") + .text() + .trim(); + const realEstateType = $( + "#artikal_glavni_div > div.artikal_lijevo > div:nth-child(3) > div > span:nth-child(3) > a > span" + ).text(); + + const price = $("#pc > p:nth-child(2)").text(); + const size = $("#dodatnapolja1 > div:nth-child(1) > div.df2").text(); + const rooms = $("#dodatnapolja1 > div:nth-child(2) > div.df2").text(); + const address = $("#dodatnapolja1 > div:nth-child(5) > div.df2").text(); + const gardenSize = $( + "#dodatnapolja1 > div:nth-child(6) > div.df2" + ).text(); + const location = $( + "#artikal_glavni_div > div.artikal_lijevo > div.op.pop.mobile-lokacija" + ).attr("data-content"); + + const time = $("time").attr("datetime"); + const olxId = $( + "#artikal_glavni_div > div.artikal_lijevo > div:nth-child(15) > div:nth-child(4) > div.df2" + ).text(); + + const descriptions = $(".artikal_detaljniopis_tekst"); + const latLngRe = /LatLng\(([0-9]+\.[0-9]+)\,\s+([0-9]+\.[0-9]+)\)/g; + const imgRe = /href":("[^"]*")/g; + const matches = latLngRe.exec(body); + let lng = "", + lat = ""; + const parsePrice = price => parseFloat(price.replace(".", "")); + + if (matches && matches.length >= 3) { + lat = matches[1]; + lng = matches[2]; + } + + const parsedPrice = parsePrice(price); + + const locationArray = location.split(","); + const region = locationArray[0]; + const municipality = locationArray[1]; + + const data = { + realEstateType: this.getCategoryId(realEstateType), + email: this.olxUrl.email, + uuid: this.olxUrl.uuid, + olxId: olxId, + url: this.olxUrl.url, + title, + price: isNaN(parsedPrice) ? 0 : parsedPrice, + size: parseFloat(size), + gardenSize: isNaN(parseFloat(gardenSize)) ? 0 : parseFloat(gardenSize), + address, + region, + municipality, + time, + shortDescription: descriptions.first().text(), + longDescription: descriptions.last().text(), + lat, + lng, + loc: [parseFloat(lat), parseFloat(lng)] + }; + + return data; + } catch (e) { + console.error("Exception caught: " + e.message); } - getCategoryId(category) { + return null; + } - switch (category) { - case 'Stanovi': - return 'stan'; + getCategoryId(category) { + switch (category) { + case "Stanovi": + return "stan"; - case 'Vikendice': - return 'vikendica' + case "Vikendice": + return "vikendica"; - case 'Kuće': - return 'kuca'; + case "Kuće": + return "kuca"; - default: - return ''; - } + default: + return ""; } + } } - diff --git a/app/services/crawlerService.js b/app/services/crawlerService.js index aaae6f7..1de43b8 100644 --- a/app/services/crawlerService.js +++ b/app/services/crawlerService.js @@ -4,8 +4,6 @@ const db = require("../models/index"); const { allMarketAlerts } = require("../helpers/db/dbHelper"); async function crawlAll() { - console.log("CRAWLER SERVICE: crawlAll"); - try { const marketAlertsFromDb = await allMarketAlerts(true); const hrefs = []; @@ -18,8 +16,6 @@ async function crawlAll() { hrefs[marketAlert.request].push(marketAlert.url); }); - console.log("CRAWLER SERVICE: GLOBAL HREFS"); - console.log(hrefs); const olxCrawler = new OlxCrawler(hrefs); const crawlers = [olxCrawler]; @@ -30,11 +26,6 @@ async function crawlAll() { try { const marketAlertsFromDb = await allMarketAlerts(false, true); - console.log( - "CRAWLER SERVICE: number of existing MarketAlerts from db: " + - marketAlertsFromDb.length - ); - const marketAlerts = []; const mergedResults = [].concat.apply([], results); @@ -56,9 +47,6 @@ async function crawlAll() { hasLocation: result.hasLocation }); } - console.log( - "CRAWLER SERVICE: Number of crawler results: " + marketAlerts.length - ); try { const filteredMarketAlerts = marketAlerts.filter( @@ -67,10 +55,6 @@ async function crawlAll() { return elem.url === url && elem.request === request; }) ); - console.log( - "CRAWLER SERVICE: Number of new crawler results: " + - filteredMarketAlerts.length - ); await db.MarketAlert.bulkCreate(filteredMarketAlerts); } catch (e) { diff --git a/app/services/notificationService.js b/app/services/notificationService.js index b49de5d..e5625ca 100644 --- a/app/services/notificationService.js +++ b/app/services/notificationService.js @@ -8,15 +8,9 @@ const { async function processNotifications() { try { const marketAlerts = await allMarketAlerts(false, false); - console.log(marketAlerts.length); await createMarketAlertEmailTemplate(); if (marketAlerts.length > 0) { - console.log( - "NOTIFICATION SERVICE: Number of new alerts: " + marketAlerts.length - ); await sendBulkEmail(marketAlerts); - } else { - console.log("NOTIFICATION SERVICE: No new alerts"); } await db.MarketAlert.update( diff --git a/tools/kantoni.html b/tools/kantoni.html index e0a4091..2d28d98 100644 --- a/tools/kantoni.html +++ b/tools/kantoni.html @@ -1,389 +1,430 @@ - - - - - - - - - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - -
- - - Mjesto - - -
- - - - - - + + + + + + + + +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ +
+ + Mjesto + + +
+ + + +