From d41ac3c094f733a8038885de3400ed7558b2b878 Mon Sep 17 00:00:00 2001 From: Petri Hienonen Date: Fri, 14 Nov 2025 11:47:49 +0200 Subject: Minor tuning --- README.adoc | 4 + app/components.js | 36 +- app/dom.js | 6 +- app/main.js | 46 +- app/map.js | 9 +- app/models.js | 44 +- scrape/go.mod | 16 +- scrape/go.sum | 25 +- scrape/html.go | 151 ++++ scrape/main.go | 248 ++++--- scrape/oikotie.html | 2016 +++++++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 2463 insertions(+), 138 deletions(-) create mode 100644 scrape/html.go create mode 100644 scrape/oikotie.html diff --git a/README.adoc b/README.adoc index fd1cc7b..6dccb53 100644 --- a/README.adoc +++ b/README.adoc @@ -69,6 +69,10 @@ go run main.go - Visual programming? Value function description with Javascript? - Notifications to user on new houses - Sharing via URL +- Tie the filters to the rest of the program +- Make scoring work +- Add historgram +- Make filters work == Analysis Data processing diff --git a/app/components.js b/app/components.js index cc08fb4..c952b08 100644 --- a/app/components.js +++ b/app/components.js @@ -666,9 +666,9 @@ export class Modal { children: house.images.slice(0, 3).map((src) => { // Wrap image in anchor tag that opens in new tab return Dom.a( + src, new DomOptions({ attributes: { - href: src, rel: "noopener noreferrer", target: "_blank", }, @@ -771,6 +771,8 @@ export class Modal { { label: "Living Area", value: `${house.livingArea} m²` }, { label: "District", value: house.district }, { label: "Rooms", value: house.rooms?.toString() ?? "N/A" }, + { label: "Lot Size", value: house.totalArea ? `${house.totalArea} m²` : "N/A" }, + { label: "Price per m²", value: house.pricePerSqm ? `${house.pricePerSqm} €` : "N/A" }, ]; for (const { label, value } of details) { const item = Dom.div( @@ -816,6 +818,38 @@ export class Modal { ), ); + frag.appendChild( + Dom.div( + new DomOptions({ + children: [ + Dom.span( + "Official Listing", + new DomOptions({ + styles: { + fontSize: "14px", + fontWeight: "bold", + marginBottom: "5px", + marginRight: "10px", + }, + }), + ), + Dom.a( + house.url, + new DomOptions({ + attributes: { + rel: "noopener noreferrer", + target: "_blank", + }, + styles: { color: "#0066cc", fontSize: "14px", wordBreak: "break-all" }, + }), + "Oikotie", + ), + ], + styles: { marginBottom: "20px" }, + }), + ), + ); + if (house.images?.length) { frag.appendChild(Modal.imageSection(house)); } diff --git a/app/dom.js b/app/dom.js index 8e73a09..11d2a0a 100644 --- a/app/dom.js +++ b/app/dom.js @@ -121,10 +121,14 @@ export class Dom { /** * Create a `` + * @param {string} url * @param {DomOptions} o + * @param {string|undefined} text */ - static a(o) { + static a(url, o, text) { const link = document.createElement("a"); + if (text) link.text = text; + link.href = url; Object.assign(link.style, o.styles); if (o.id) link.id = o.id; for (const cls of o.classes) link.classList.add(cls); diff --git a/app/main.js b/app/main.js index 9abb300..da52152 100644 --- a/app/main.js +++ b/app/main.js @@ -50,23 +50,13 @@ export class App { this.#filters, this.#weights, () => { - this.#filtered = this.collection?.houses.filter((h) => h.matchesFilters(this.#filters)); - const filteredIds = this.#filtered.map((h) => h.id); - this.#map.updateHouseVisibility(filteredIds); - - const stats = App.#getStats(this.#filtered); - this.#stats.replaceWith(stats); - this.#stats = stats; + this.#applyFiltersAndScoring(); }, (key, value) => { if (key in this.#weights) { this.#weights[/** @type {keyof Weights} */ (key)] = value; } - App.#recalculateScores(this.collection?.houses, this.#weights); - this.#map.updateHousesColor(this.#houseParameter); - const stats = App.#getStats(this.#filtered); - this.#stats.replaceWith(stats); - this.#stats = stats; + this.#applyFiltersAndScoring(); }, (param) => { this.#houseParameter = param; @@ -189,7 +179,9 @@ export class App { async #initialize(loading) { try { this.collection = await Collection.get(); - this.#filtered = this.collection.houses.slice(); + + App.#recalculateScores(this.collection.houses, this.#weights); + this.#filtered = this.collection.houses.filter((h) => h.matchesFilters(this.#filters)); this.#map.initialize(this.collection, this.#houseParameter, this.#areaParameter); this.#sidebar.updateDistricts(this.collection.houses); @@ -211,9 +203,33 @@ export class App { static #recalculateScores(houses, weights) { for (const h of houses) { h.scores.current = Math.round(ScoringEngine.calculate(h, weights)); + h.value = h.scores.current; } } + /** + * Apply filters and recalculate scores + */ + #applyFiltersAndScoring() { + if (!this.collection) return; + + // First recalculate all scores with current weights + App.#recalculateScores(this.collection.houses, this.#weights); + + // Then apply filters + this.#filtered = this.collection.houses.filter((h) => h.matchesFilters(this.#filters)); + + // Update map with filtered houses and new scores + const filteredIds = this.#filtered.map((h) => h.id); + this.#map.updateHouseVisibility(filteredIds); + this.#map.updateHousesColor(this.#houseParameter); + + // Update statistics + const stats = App.#getStats(this.#filtered); + this.#stats.replaceWith(stats); + this.#stats = stats; + } + /** * Update statistics display using DOM methods * @param {House[]} filtered @@ -223,14 +239,14 @@ export class App { new DomOptions({ children: [ Dom.strong(filtered.length.toString()), - document.createTextNode(" houses shown • Average score: "), + Dom.span(" houses shown • Average score: "), Dom.strong( (filtered.length ? Math.round(filtered.reduce((s, h) => s + h.scores.current, 0) / filtered.length) : 0 ).toString(), ), - document.createTextNode(" • Use weights sliders to adjust scoring"), + Dom.span(" • Use weights sliders to adjust scoring"), ], id: "stats", styles: { diff --git a/app/map.js b/app/map.js index d27a009..d63da17 100644 --- a/app/map.js +++ b/app/map.js @@ -817,14 +817,17 @@ export class MapEl { * @param {AreaParam} param */ updateArea(param) { - const values = this.#collection?.statisticalAreas.map((area) => area.getValue(param)); + const values = this.#collection?.statisticalAreas + .map((area) => area.getValue(param)) + .filter((x) => !Number.isNaN(x)) + .sort(); const range = { max: Math.max(...values), min: Math.min(...values) }; const statAreaPolygons = this.svg.querySelectorAll("#statistical-areas polygon"); statAreaPolygons.forEach((polygon) => { const areaId = polygon.getAttribute("data-id"); const area = this.#collection?.statisticalAreas.find((a) => a.id === areaId); - if (area) { - const value = area.getValue(param); + const value = area?.getValue(param); + if (area && value && !Number.isNaN(value)) { const normalized = MapMath.normalize(value, range.min, range.max); polygon.setAttribute( "fill", diff --git a/app/models.js b/app/models.js index 8cd1b5e..b4584f7 100644 --- a/app/models.js +++ b/app/models.js @@ -238,11 +238,17 @@ export class Geospatial { * @param {number} [schoolDistance] * @param {number} [railwayDistance] */ - constructor(marketDistance = 0, schoolDistance = 0, railwayDistance = 0) { + constructor( + marketDistance = Number.POSITIVE_INFINITY, + schoolDistance = Number.POSITIVE_INFINITY, + railwayDistance = Number.POSITIVE_INFINITY, + ) { this.marketDistance = marketDistance; this.schoolDistance = schoolDistance; this.railwayDistance = railwayDistance; - // Removed: crimeRate, safetyIndex, s2StudentRatio + this.distanceTrain = Number.POSITIVE_INFINITY; + this.distanceLightRail = Number.POSITIVE_INFINITY; + this.distanceTram = Number.POSITIVE_INFINITY; } /** @param {GeospatialJson} data @returns {Geospatial} */ @@ -538,6 +544,8 @@ export class House { * @param {Scores} scores * @param {string[]} images * @param {Geospatial} [geospatial] + * @param {StatisticalArea|null} statisticalArea + * @param {string} url */ constructor( id, @@ -563,6 +571,9 @@ export class House { images = [], geospatial = new Geospatial(), value = 0, + statisticalArea = null, + url = "", + pricePerSqm = 0, ) { this.id = id; this.address = address; @@ -587,10 +598,9 @@ export class House { this.images = images; this.geospatial = geospatial; this.value = value; - this.distanceTrain = 0; - this.distanceLightRail = 0; - this.distanceTram = 0; - this.statisticalArea = null; + this.statisticalArea = statisticalArea; + this.url = url; + this.pricePerSqm = pricePerSqm; } /** @param {HouseParameter} param */ @@ -659,6 +669,10 @@ export class House { new Scores(0), data.images || [], new Geospatial(), + 0, + null, + data.url || "", + rawData.pricePerSqm || 0, ); } @@ -795,11 +809,11 @@ export class Collection { house.statisticalArea = this.#findStatisticalArea(house.coordinates); // Calculate transit distances - house.distanceTrain = this.#calculateMinDistanceToStations( + house.geospatial.distanceTrain = this.#calculateMinDistanceToStations( house.coordinates, this.trainStations, ); - house.distanceLightRail = this.#calculateMinDistanceToStations( + house.geospatial.distanceLightRail = this.#calculateMinDistanceToStations( house.coordinates, this.lightRailStops, ); @@ -1065,20 +1079,22 @@ export class ScoringEngine { } // Transit distances (closer is better, but not too close) - if (weights.distanceTrain > 0 && house.distanceTrain > 0) { - const trainScore = ScoringEngine.calculateTransitScore(house.distanceTrain); + if (weights.distanceTrain > 0 && house.geospatial.distanceTrain > 0) { + const trainScore = ScoringEngine.calculateTransitScore(house.geospatial.distanceTrain); score += trainScore * weights.distanceTrain; totalWeight += weights.distanceTrain; } - if (weights.distanceLightRail > 0 && house.distanceLightRail > 0) { - const lightRailScore = ScoringEngine.calculateTransitScore(house.distanceLightRail); + if (weights.distanceLightRail > 0 && house.geospatial.distanceLightRail > 0) { + const lightRailScore = ScoringEngine.calculateTransitScore( + house.geospatial.distanceLightRail, + ); score += lightRailScore * weights.distanceLightRail; totalWeight += weights.distanceLightRail; } - if (weights.distanceTram > 0 && house.distanceTram > 0) { - const tramScore = ScoringEngine.calculateTransitScore(house.distanceTram); + if (weights.distanceTram > 0 && house.geospatial.distanceTram > 0) { + const tramScore = ScoringEngine.calculateTransitScore(house.geospatial.distanceTram); score += tramScore * weights.distanceTram; totalWeight += weights.distanceTram; } diff --git a/scrape/go.mod b/scrape/go.mod index c7a1086..bec6e3f 100644 --- a/scrape/go.mod +++ b/scrape/go.mod @@ -3,6 +3,12 @@ module tammi.cc/housing go 1.25.2 require ( + github.com/minio/minio-go/v7 v7.0.95 + golang.org/x/net v0.47.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/goccy/go-json v0.10.5 // indirect @@ -11,12 +17,12 @@ require ( github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/minio/crc64nvme v1.0.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minio-go/v7 v7.0.95 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/tinylib/msgp v1.3.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/scrape/go.sum b/scrape/go.sum index 9fdb97e..dde91de 100644 --- a/scrape/go.sum +++ b/scrape/go.sum @@ -1,3 +1,5 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= @@ -19,15 +21,22 @@ github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYE github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scrape/html.go b/scrape/html.go new file mode 100644 index 0000000..a1d1bbe --- /dev/null +++ b/scrape/html.go @@ -0,0 +1,151 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "strings" + + "golang.org/x/net/html" +) + +type PropertyField struct { + Title string `json:"title"` + Value string `json:"value"` +} + +type PropertySection struct { + Section string `json:"section"` + Fields []PropertyField `json:"fields"` +} + +func test() { + // Read the HTML file + htmlContent, err := os.ReadFile("oikotie.html") + if err != nil { + log.Fatal("Error reading file:", err) + } + + // Parse the HTML + sections, err := parsePropertyHTML(string(htmlContent)) + if err != nil { + log.Fatal("Error parsing HTML:", err) + } + + // Convert to JSON + jsonData, err := json.MarshalIndent(sections, "", " ") + if err != nil { + log.Fatal("Error marshaling JSON:", err) + } + + // Write to file + err = os.WriteFile("property_data.json", jsonData, 0644) + if err != nil { + log.Fatal("Error writing JSON file:", err) + } + + fmt.Println("Successfully parsed property data and saved to property_data.json") +} + +func parsePropertyHTML(htmlContent string) ([]PropertySection, error) { + doc, err := html.Parse(strings.NewReader(htmlContent)) + if err != nil { + return nil, err + } + + var sections []PropertySection + var currentSection *PropertySection + + // Recursive function to traverse the HTML nodes + var traverse func(*html.Node) + traverse = func(n *html.Node) { + if n.Type == html.ElementNode { + // Check for section headers + if n.Data == "h3" && hasClass(n, "heading") && hasClass(n, "heading--title-2") { + if currentSection != nil && len(currentSection.Fields) > 0 { + sections = append(sections, *currentSection) + } + + sectionName := extractText(n) + currentSection = &PropertySection{ + Section: sectionName, + Fields: []PropertyField{}, + } + } + + // Check for info table rows + if n.Data == "div" && hasClass(n, "info-table__row") { + if currentSection != nil { + field := parseInfoTableRow(n) + if field.Title != "" { + currentSection.Fields = append(currentSection.Fields, field) + } + } + } + } + + // Traverse child nodes + for c := n.FirstChild; c != nil; c = c.NextSibling { + traverse(c) + } + } + + traverse(doc) + + // Don't forget to add the last section + if currentSection != nil && len(currentSection.Fields) > 0 { + sections = append(sections, *currentSection) + } + + return sections, nil +} + +func parseInfoTableRow(n *html.Node) PropertyField { + var field PropertyField + + var traverseRow func(*html.Node) + traverseRow = func(node *html.Node) { + if node.Type == html.ElementNode { + if node.Data == "dt" && hasClass(node, "info-table__title") { + field.Title = extractText(node) + } + if node.Data == "dd" && hasClass(node, "info-table__value") { + field.Value = extractText(node) + } + } + + for c := node.FirstChild; c != nil; c = c.NextSibling { + traverseRow(c) + } + } + + traverseRow(n) + return field +} + +func hasClass(n *html.Node, className string) bool { + for _, attr := range n.Attr { + if attr.Key == "class" && strings.Contains(attr.Val, className) { + return true + } + } + return false +} + +func extractText(n *html.Node) string { + var text strings.Builder + + var extract func(*html.Node) + extract = func(node *html.Node) { + if node.Type == html.TextNode { + text.WriteString(node.Data) + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + extract(c) + } + } + + extract(n) + return strings.TrimSpace(text.String()) +} diff --git a/scrape/main.go b/scrape/main.go index 7ef5ce4..fed0397 100644 --- a/scrape/main.go +++ b/scrape/main.go @@ -19,10 +19,6 @@ import ( "github.com/minio/minio-go/v7/pkg/credentials" ) -// --------------------------------------------------------------------- -// 1. CONFIG & HELPERS -// --------------------------------------------------------------------- - type Config struct { CouchURL string CouchDB string @@ -46,10 +42,6 @@ func getEnvBool(key string, def bool) bool { return def } -// --------------------------------------------------------------------- -// 2. S3 / MINIO CLIENT (public bucket – no keys) -// --------------------------------------------------------------------- - type S3Client struct { client *minio.Client bucket string @@ -62,44 +54,53 @@ func NewS3Client(endpoint, bucket string, useSSL bool) (*S3Client, error) { Secure: useSSL, }) if err != nil { - return nil, err + return nil, fmt.Errorf("minio client creation failed: %w", err) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() exists, err := c.BucketExists(ctx, bucket) - if err != nil || !exists { - return nil, fmt.Errorf("bucket %s not accessible", bucket) + if err != nil { + return nil, fmt.Errorf("bucket check failed: %w", err) + } + if !exists { + return nil, fmt.Errorf("bucket %s does not exist", bucket) } return &S3Client{client: c, bucket: bucket}, nil } // UploadFromURL downloads a remote image, puts it in the bucket and returns the public URL func (s *S3Client) UploadFromURL(imgURL, key string) (string, error) { + log.Printf("Downloading image: %s", imgURL) resp, err := http.Get(imgURL) if err != nil { - return "", err + return "", fmt.Errorf("HTTP GET failed: %w", err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("img status %d", resp.StatusCode) + return "", fmt.Errorf("image download failed with status %d", resp.StatusCode) } + data, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return "", fmt.Errorf("reading response body failed: %w", err) } - _, err = s.client.PutObject(context.Background(), s.bucket, key, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + _, err = s.client.PutObject(ctx, s.bucket, key, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ ContentType: "image/webp", }) if err != nil { - return "", err + return "", fmt.Errorf("S3 upload failed: %w", err) } - return fmt.Sprintf("https://%s/%s/%s", s.client.EndpointURL().Host, s.bucket, key), nil + + publicURL := fmt.Sprintf("https://%s/%s/%s", s.client.EndpointURL().Host, s.bucket, key) + log.Printf("Successfully uploaded image: %s", publicURL) + return publicURL, nil } -// --------------------------------------------------------------------- -// 3. HOUSE MODEL -// --------------------------------------------------------------------- - type House struct { ID string `json:"_id"` Rev string `json:"_rev,omitempty"` @@ -113,10 +114,6 @@ type House struct { ScrapedAt time.Time `json:"scraped_at"` } -// --------------------------------------------------------------------- -// 4. COUCHDB CLIENT (simplified – only Upsert) -// --------------------------------------------------------------------- - type CouchClient struct { baseURL string database string @@ -132,33 +129,41 @@ func NewCouchClient(base, db string) *CouchClient { } func (c *CouchClient) Upsert(h *House) error { - body, _ := json.Marshal(h) - reqURL := fmt.Sprintf("%s/%s/%s", c.baseURL, c.database, h.ID) - req, _ := http.NewRequest("PUT", reqURL, bytes.NewReader(body)) + body, err := json.Marshal(h) + if err != nil { + return fmt.Errorf("JSON marshal failed: %w", err) + } + + reqURL := fmt.Sprintf("%s/%s/%s", c.baseURL, c.database, url.PathEscape(h.ID)) + req, err := http.NewRequest("PUT", reqURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("request creation failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := c.client.Do(req) if err != nil { - return err + return fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("couch %d: %s", resp.StatusCode, string(b)) + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("couchDB responded with status %d: %s", resp.StatusCode, string(body)) } + var rev struct { Rev string `json:"rev"` } - json.NewDecoder(resp.Body).Decode(&rev) + if err := json.NewDecoder(resp.Body).Decode(&rev); err != nil { + return fmt.Errorf("decoding response failed: %w", err) + } h.Rev = rev.Rev return nil } -// --------------------------------------------------------------------- -// 5. OIKOTIE SCRAPER (single struct – everything in main.go) -// --------------------------------------------------------------------- - type OikotieScraper struct { client *http.Client s3 *S3Client @@ -179,8 +184,7 @@ func NewOikotieScraper(s3 *S3Client) *OikotieScraper { } } -// ---- token handling ------------------------------------------------- -func (osi *OikotieScraper) loadTokens() { +func (osi *OikotieScraper) loadTokens() error { osi.otaToken = getEnv("OTA_TOKEN", "") osi.otaCuid = getEnv("OTA_CUID", "") osi.otaLoaded = getEnv("OTA_LOADED", "") @@ -189,94 +193,132 @@ func (osi *OikotieScraper) loadTokens() { if osi.otaToken == "" || osi.otaCuid == "" || osi.otaLoaded == "" || osi.phpSessID == "" { log.Println("Missing one or more tokens – please enter them now:") r := bufio.NewReader(os.Stdin) + if osi.otaToken == "" { fmt.Print("OTA-token: ") - osi.otaToken, _ = r.ReadString('\n') - osi.otaToken = strings.TrimSpace(osi.otaToken) + token, err := r.ReadString('\n') + if err != nil { + return fmt.Errorf("reading OTA-token failed: %w", err) + } + osi.otaToken = strings.TrimSpace(token) } + if osi.otaCuid == "" { fmt.Print("OTA-cuid: ") - osi.otaCuid, _ = r.ReadString('\n') - osi.otaCuid = strings.TrimSpace(osi.otaCuid) + cuid, err := r.ReadString('\n') + if err != nil { + return fmt.Errorf("reading OTA-cuid failed: %w", err) + } + osi.otaCuid = strings.TrimSpace(cuid) } + if osi.otaLoaded == "" { fmt.Print("OTA-loaded: ") - osi.otaLoaded, _ = r.ReadString('\n') - osi.otaLoaded = strings.TrimSpace(osi.otaLoaded) + loaded, err := r.ReadString('\n') + if err != nil { + return fmt.Errorf("reading OTA-loaded failed: %w", err) + } + osi.otaLoaded = strings.TrimSpace(loaded) } + if osi.phpSessID == "" { fmt.Print("PHPSESSID: ") - osi.phpSessID, _ = r.ReadString('\n') - osi.phpSessID = strings.TrimSpace(osi.phpSessID) + sessID, err := r.ReadString('\n') + if err != nil { + return fmt.Errorf("reading PHPSESSID failed: %w", err) + } + osi.phpSessID = strings.TrimSpace(sessID) } } + return nil } -// ---- main scrape loop ----------------------------------------------- func (os *OikotieScraper) ScrapeAll(ctx context.Context, couch *CouchClient) error { - os.loadTokens() + if err := os.loadTokens(); err != nil { + return fmt.Errorf("loading tokens failed: %w", err) + } limit := 24 offset := 0 totalSaved := 0 + retryCount := 0 + maxRetries := 3 for { select { case <-ctx.Done(): + log.Println("Context cancelled, stopping scrape") return ctx.Err() case <-os.rateLimiter: + log.Printf("Fetching page with offset %d, limit %d", offset, limit) cards, found, err := os.fetchPage(offset, limit) if err != nil { if strings.Contains(err.Error(), "401") { - log.Println("401 – re-entering tokens") - os.loadTokens() + log.Println("401 Unauthorized – re-entering tokens") + if err := os.loadTokens(); err != nil { + return fmt.Errorf("reloading tokens failed: %w", err) + } + retryCount++ + if retryCount > maxRetries { + return fmt.Errorf("max retries reached for authentication") + } continue } - log.Printf("fetch error (offset %d): %v", offset, err) + + log.Printf("Fetch error (offset %d): %v", offset, err) + retryCount++ + if retryCount > maxRetries { + return fmt.Errorf("max retries reached for fetching") + } time.Sleep(5 * time.Second) continue } + + retryCount = 0 // Reset retry count on successful fetch if len(cards) == 0 { - log.Printf("No cards at offset %d – finished", offset) - break + log.Printf("No cards found at offset %d – finished scraping", offset) + return nil } - for _, c := range cards { + savedInBatch := 0 + for i, c := range cards { + log.Printf("Processing card %d/%d: %s", i+1, len(cards), c.ID) h, err := os.convertCard(c) if err != nil { - log.Printf("convert error %s: %v", c.ID, err) + log.Printf("Convert error for card %s: %v", c.ID, err) continue } if err := couch.Upsert(h); err != nil { - log.Printf("couch upsert %s: %v", h.ID, err) + log.Printf("CouchDB upsert failed for %s: %v", h.ID, err) } else { totalSaved++ + savedInBatch++ } } - log.Printf("offset %d-%d → %d new (total %d/%d)", offset, offset+len(cards)-1, len(cards), totalSaved, found) + log.Printf("Batch %d-%d: %d/%d cards saved (total: %d, found: %d)", + offset, offset+len(cards)-1, savedInBatch, len(cards), totalSaved, found) if offset+len(cards) >= found { - log.Printf("Reached end – %d cards saved", totalSaved) - break + log.Printf("Reached end of results – %d cards saved in total", totalSaved) + return nil } offset += limit } } - return nil } -// ---- API call -------------------------------------------------------- +// Fixed: cardId can be number or string, so use json.Number type apiCard struct { - ID string `json:"cardId"` - Type int `json:"cardType"` - SubType int `json:"cardSubType"` - URL string `json:"url"` - Status int `json:"status"` - Data json.RawMessage - Location json.RawMessage - Company json.RawMessage + ID json.Number `json:"cardId"` + Type int `json:"cardType"` + SubType int `json:"cardSubType"` + URL string `json:"url"` + Status int `json:"status"` + Data json.RawMessage `json:"data"` + Location json.RawMessage `json:"location"` + Company json.RawMessage `json:"company"` Medias []struct { ImageMobileWebPx2 string `json:"imageMobileWebPx2"` } `json:"medias"` @@ -294,9 +336,11 @@ func (os *OikotieScraper) fetchPage(offset, limit int) ([]apiCard, int, error) { q.Add("sortBy", "published_sort_desc") reqURL := os.baseURL + "?" + q.Encode() - req, _ := http.NewRequest("GET", reqURL, nil) + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, 0, fmt.Errorf("creating request failed: %w", err) + } - // ---- headers ---------------------------------------------------- req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0") req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Referer", "https://asunnot.oikotie.fi/myytavat-asunnot?pagination=1&locations=%5B%5B64,6,%22Helsinki%22%5D%5D&cardType=100&buildingType%5B%5D=4&buildingType%5B%5D=8&buildingType%5B%5D=32&buildingType%5B%5D=128&buildingType%5B%5D=64&buildingType%5B%5D=512") @@ -307,7 +351,7 @@ func (os *OikotieScraper) fetchPage(offset, limit int) ([]apiCard, int, error) { resp, err := os.client.Do(req) if err != nil { - return nil, 0, err + return nil, 0, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() @@ -315,30 +359,40 @@ func (os *OikotieScraper) fetchPage(offset, limit int) ([]apiCard, int, error) { return nil, 0, fmt.Errorf("401 Unauthorized") } if resp.StatusCode != 200 { - b, _ := io.ReadAll(resp.Body) - return nil, 0, fmt.Errorf("status %d: %s", resp.StatusCode, string(b)) + body, _ := io.ReadAll(resp.Body) + return nil, 0, fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) } var payload struct { Found int `json:"found"` Cards []apiCard `json:"cards"` } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return nil, 0, err + + // Read the body first for better error reporting + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, 0, fmt.Errorf("reading response body failed: %w", err) + } + + if err := json.Unmarshal(body, &payload); err != nil { + log.Printf("Raw response: %s", string(body)) + return nil, 0, fmt.Errorf("JSON unmarshal failed: %w", err) } return payload.Cards, payload.Found, nil } -// ---- conversion ------------------------------------------------------- func (os *OikotieScraper) convertCard(c apiCard) (*House, error) { + // Convert json.Number to string for the ID + cardID := c.ID.String() + h := &House{ - ID: "oikotie_" + c.ID, + ID: "oikotie_" + cardID, Source: "oikotie", - URL: "https://asunnot.oikotie.fi" + c.URL, + URL: c.URL, Status: c.Status, Type: c.Type, SubType: c.SubType, - ScrapedAt: time.Now(), + ScrapedAt: time.Now().UTC(), Raw: map[string]json.RawMessage{ "data": c.Data, "location": c.Location, @@ -346,7 +400,7 @@ func (os *OikotieScraper) convertCard(c apiCard) (*House, error) { }, } - // ---- images → download → S3 → store public URL -------------------- + // Process images for i, m := range c.Medias { if m.ImageMobileWebPx2 == "" { continue @@ -354,19 +408,20 @@ func (os *OikotieScraper) convertCard(c apiCard) (*House, error) { key := fmt.Sprintf("%s/img_%d.webp", h.ID, i) publicURL, err := os.s3.UploadFromURL(m.ImageMobileWebPx2, key) if err != nil { - log.Printf("image upload failed %s: %v", key, err) + log.Printf("Image upload failed for %s: %v", key, err) continue } h.Images = append(h.Images, publicURL) } + + log.Printf("Successfully converted card %s with %d images", cardID, len(h.Images)) return h, nil } -// --------------------------------------------------------------------- -// 6. MAIN -// --------------------------------------------------------------------- - func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Println("Starting Oikotie scraper...") + cfg := Config{ CouchURL: getEnv("COUCHDB_URL", "https://couch.tammi.cc"), CouchDB: getEnv("COUCHDB_DATABASE", "asunnot"), @@ -375,21 +430,32 @@ func main() { S3UseSSL: getEnvBool("S3_USE_SSL", true), } + log.Printf("Configuration: CouchDB=%s, S3=%s/%s", cfg.CouchURL, cfg.S3Endpoint, cfg.S3Bucket) + s3, err := NewS3Client(cfg.S3Endpoint, cfg.S3Bucket, cfg.S3UseSSL) if err != nil { - log.Fatal("S3 init:", err) + log.Fatalf("S3 initialization failed: %v", err) } + log.Println("S3 client initialized successfully") couch := NewCouchClient(cfg.CouchURL, cfg.CouchDB) + log.Println("CouchDB client initialized successfully") scraper := NewOikotieScraper(s3) + log.Println("Oikotie scraper initialized successfully") ctx, cancel := context.WithCancel(context.Background()) defer cancel() - log.Println("Starting full Oikotie scrape …") + // Handle graceful shutdown + go func() { + <-ctx.Done() + log.Println("Shutting down...") + }() + + log.Println("Starting full Oikotie scrape...") if err := scraper.ScrapeAll(ctx, couch); err != nil { - log.Fatal("scrape failed:", err) + log.Fatalf("Scrape failed: %v", err) } - log.Println("All done!") -} + log.Println("Scraping completed successfully!") +} \ No newline at end of file diff --git a/scrape/oikotie.html b/scrape/oikotie.html new file mode 100644 index 0000000..9e5351f --- /dev/null +++ b/scrape/oikotie.html @@ -0,0 +1,2016 @@ + + + + + + +78,5 m² Sarvastonkaari 3 G, 00840 Helsinki Paritalo 3h myynnissä - Oikotie 23911499 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + + +
+
+ + + +
+
+
+
+
+ +
+
+
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+ + + +
+ + +
+
+
+ +
+

305 000 €78,5 m²

Sarvastonkaari 3 G, Laajasalo, Helsinki3h, avok. kph, s (yj. mukaan 3h+k+s)

+
+ +
+
+ + + + +
+
+
+
+ +
+
+
Velaton hinta
+
305 000 €
+
+
+
Hoitovastike
+
525,95 € / kk
+
+
+
+
+ +
+
+
Asuinpinta-ala
+
78,5 m²
+
+
+
Huoneita
+
3
+
+
+
+
+ +
+
+
Kerros
+
2 / 2
+
+
+
+
+ +
+
+
Rakennusvuosi
+
1978
+
+
+
Rakennuksen tyyppi
+
Paritalo
+
+
+
+
+ +
+
+
Kaupunginosa
+
Laajasalo
+
+
+
Kaupunki
+
Helsinki
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+ + + + +
+ +
+
+

Tervetuloa Sarvastonkaarelle – kotoisa kaksikerroksinen paritalo meren läheisyydessä tarjoaa viihtyisän ja idyllisen asuinympäristön pienelle perheelle.

Tässä viehättävässä läpitalon huoneistossa lämpimät sisustussävyt tuovat tiloihin kodikasta tunnelmaa ja viihtyisyyttä. Alakerran avara olohuone ja ruokailutila muodostavat yhtenäisen ja kutsuvan kokonaisuuden, joka soveltuu erinomaisesti sekä arjen että juhlan tarpeisiin.

Olohuoneen ikkunoista avautuvat näkymät asunnon omalle, tilavalle takapihalle, jossa on tilaa niin grillaukseen, ruokailuun kuin vaikkapa omalle trampoliinillekin. Yläkerrassa sijaitsevat makuuhuoneet lisäävät asumismukavuutta tarjoten yksityisyyttä ja rauhaa. Yläkerrassa on myös tilava kylpyhuone, jonka yhteydessä on sauna sekä vilvoitteluparveke. +Huoneistolle kuuluu myös reilunkokoinen ulkovarasto sekä oma parkkipaikka.

Asunto sijaitsee yhteisöllisessä ja arvostetussa taloyhtiössä, joka on vuonna 2011 palkittu pääkaupunkiseudun mukavimpana taloyhtiönä. Yhtiön 107 asuntoa on ripoteltu viiden pihan ympärille, joista jokainen on itsessään oma leikkikenttänsä tehden paikasta lapsiystävällisen ja kylämäisen. Asukkailla on monipuolista yhteistä toimintaa ja taloyhtiön kerhohuoneella kokoontuvat muun muassa nyrkkeilyryhmä, lasten taidekerho, joogaajat ja tanssijat. Asukkaat voivat varata tilaa maksutta myös omaan käyttöön, kuten lastenjuhliin. Kerhohuoneen yhteydessä on myös pesutupa.

Taloyhtiöön on vuosien saatossa tehty useita merkittäviä kunnostuksia. Tulevia remontteja suunnitellaan ja tehdään järjestelmällisesti yhtiön arvon ja kunnon säilyttämiseksi. Tämä varmistaa myös asukkailleen miellyttävän asuinympäristön pitkälle tulevaisuuteen. Seuraavan viiden vuoden korjaussuunnitelmassa ei ole tiedossa suuria hankkeita.

Laajasalon saari on lapsiperheiden rakastama kotisaari, jossa on hyvä olla ja elää. Alueen harrastusmahdollisuudet aktiiviselle perheelle ovat erinomaiset ja seuratoiminta näiden puitteissa monipuolista. Laajasalon alueella on useita harraste- ja liikuntatiloja, uimarantoja, sekä upeat ulkoilumaastot ja merenrannan rantareitit, jotka lähtevät lähes kotiovelta. Lähistöltä, löytyvät leikkipuistot, päiväkodit, ylä- ja alakoulut ja kattavat päivittäispalvelut ovat kauppakeskus Saaressa, reilun kilometrin päässä.

Lyhyt matka Sarvaston venesatamaan mahdollistaa myös merelliset harrasteet.

Lämpimästi tervetuloa tutustumaan – hurmaavat puitteet ja tilava takapiha tekevät varmasti vaikutuksen!

Pia Lundenberg, +040 522 1967 | pia.lundenberg@bo.fi

OLETKO MYYMÄSSÄ ASUNTOASI?

Varaa minut helposti kalenterini kautta arvioimaan kotiasi.

https://bo.fi/henkilo/pia-lundenberg/#kalenteri +

+ + +
+ +
+
+

Perustiedot

+
+
+
Sijainti
+
Sarvastonkaari 3 G, 00840 Helsinki
+
+
+
Kaupunginosa
+
Laajasalo
+
+
+
Kohdenumero
+
21055475
+
+
+
Kerros
+
2 / 2
+
+
+
Asuinpinta-ala
+
78,5 m²
+
+
+
Tontin pinta-ala
+
3,01 ha
+
+
+
Kokonaispinta-ala
+
78,5 m²
+
+
+
Pinta-alojen lisätiedot
+
yhtiöjärjestyksen mukainen, isännöitsijäntodistuksen mukainen Mainittu pinta-ala saattaa tämän ikäisessä kohteessa (rakennettu ennen vuotta 1992) poiketa olennaisesti nykyisten standardien mukaan laskettavasta asuinpinta-alasta. Todellinen asuinpinta-ala voi tarkistusmittauksen jälkeen olla yhtiöjärjestyksessä, isännöitsijäntodistuksessa ja esitteessä mainittua pienempi tai suurempi. Pinta-alaa ei ole tarkistusmitattu.
+
+
+
Huoneiston kokoonpano
+
3h, avok. kph, s (yj. mukaan 3h+k+s)
+
+
+
Huoneita
+
3
+
+
+
Kunto
+
Tyydyttävä
+
+
+
Lisätietoa vapautumisesta
+
sopimuksen mukaan
+
+
+
Keittiön varusteet
+
Liesi: induktio, erillisuuni, liesitaso. Työtasot: laminaatti. Kylmäsäilytys: jääkaappi/pakastin. Varustus: liesituuletin, astianpesukone, kiinteät valaisimet.
+
+
+
Parveke
+
Kyllä
+
+
+
Parvekkeen lisätiedot
+
Tyyppi: ulostyönnetty, muu. Suunta: itä
+
+
+
Kylpyhuoneen varusteet
+
Pesutilojen kuvaus: Kph:n katto puupaneloitu. Varustus: suihkuseinä, pesukoneliitäntä, lattialämmitys, wc-istuin, suihku, peili, kiinteät valaisimet, kylpyhuonekaapisto, asennettuja erikoisvarusteita.
+
+
+
Säilytystilat
+
Kuvaus: Huoneistoon kuuluu erillinen suuri ulkovarasto, n. 6 neliötä. Ulkovarastossa on vesipiste.ulkovarasto
+
+
+
Näkymät
+
Läpitalon huoneisto, keittiö ja toinen makuuhuone, n. lännen suuntaan, ja makuuhuone ja oh idän suuntaan. +Takapihalta kauniit näkymät Sarvaston venesataman suuntaan. Takapihan puolella terassi n. 25 neliötä, jonka jatkeena aidattu takapiha n. 100 neliötä, joka rajautuu puistoon, alue on kaavoitettu puistoalueeksi, johon ei ole suunniteltu rakentamista. +
+
+
+
Tulevat remontit
+
Tulevat suunnitellut korjaushankkeet vuosille 2025 - 2033 +2025 Maalausurakan takuutarkastus, 7-2025 mennessä, aloitus keväällä 2025 +2025 Jatketaan kattojen pinnoitusten uusimista +2025–2028 Aitojen pienkorjauksia +2026 Ikkunoiden takuutarkastus, 10-2026 mennessä +2026 Taloyhtiön kuntoarvio ja kaikkien märkätilojen kartoitus +2025-2026 Taloyhtiön kylttien ja opasteiden uusiminen +2027 Asuntojen numerovalojen uusiminen +2033 Palovaroittiminen uusiminen
+
+
+
Tehdyt remontit
+
Huoneistosta on poistettu alakerran WC:n parikymmentä vuotta sitten, muutostyöstä ei ole saatavilla asiakirjoja. TALOYHTIÖSSÄ: 2025 Palovaroittimien asennus, H-talon vajonneita viemäreitä ja kannakkeita uusittiin ja korjattiin. +2024 Sähköjärjestelmien huoltokierros +2024 Ikkunoiden uusiminen ja terassiovien osittainen uusiminen +2024 Leikkipaikkojen turvallisuuden parantaminen +2023 Asuintalojen, varastojen ja huoltorakennuksen julkisivujen huoltomaalaus, sekä +päätyasuntojen varastojen seinien lahovauriokorjaus +2023 Käyttövesijärjestelmän tasapainotus +2023 Kattojen huoltotarkastus ja pienkorjaukset +2022 Kerhotilan kunnostus +2022 Sähköautojen latauspisteiden asennus +2021 Lämmönjakohuoneen saneeraus +2021 Pysäköintialueiden saneeraus ja sähköistyksen uusiminen +2020 Aitojen kunnostus ja maalaus +2019 Parvekkeiden kuntotarkastus +2019 IV-kanavien/hormien nuohous +2018 Ikkunoiden kuntotarkastus +2015 Kaapeli-TV +2015 Taloyhtiölaajakaista +2014 Postilaatikot +2013 Aidat +2013 Valaistuksen kunnostus/lisääminen +2013 Ovien kunnostaminen +2012 Seinärakenteen peruskorjaus +2012 Kattojen huoltomaalaus +2011 Leikkipaikat ja lukitus +2008 Alapohjan peruskorjaus +2005 Lämmitysjärjestelmän tasapainotus +2005 Koekorjaussuunnitelma +2004 Kosteuskartoitus +2004 Maaperämittaus +2004 Antennijärjestelmän digitalisointi +2003 Kuntoarvio +2002 Käyttövesiputket uusittu +1999 Parvekkeet
+
+
+
Rannan omistus
+
Ei rantaa
+
+
+
Asunnossa sauna
+
Kyllä
+
+
+
Saunan lisätiedot
+
Kuvaus: Sauna kokonaan paneloitu. sähkökiuas
+
+
+
Asumistyyppi
+
Omistus
+
+
+
Lisätiedot
+
Muuta kauppaan kuuluvaa: Olohuoneessa on puusepän tekemä, katonrajassa huonetta kiertävä kirjahylly, joka jää asuntoon.
+
+
+
Kohde on
+
Osakehuoneisto
+
+
+
Tietoliikennepalvelut
+
laajakaista
+
+
+
+
+

Hinta

+
+
+
Velaton hinta
+
305 000 €
+
+
+
Myyntihinta
+
291 651,64 €
+
+
+
Lainaosuuden maksu
+
Kyllä
+
+
+
Neliöhinta
+
3 885,35 € / m²
+
+
+
Velkaosuus
+
13 348,36 €
+
+
+
+
Lisätietoa velkaosuudesta
+
POV2 Nordea -1142 Parkkipaikka 4 025,25 e
+POV3 Nordea -6813 julkisivumaalaus 3 159,25 e
+POV4 Hypo -0420 Ikkunat ja terassiovet 6 163,86 e
+
+
+
+
+
+ + +
+ +
+ + +
+
+
+ + +
+

Vastikkeet

+
+
+
Hoitovastike
+
525,95 € / kk
+
+
+
Pääomavastike
+
117,77 € / kk
+
+
+
Yhtiövastike yhteensä
+
643,72 € / kk
+
+
+
+
Lisätietoa vastikkeista
+

Pov 4 Ikkunat ja terassiovet, 0,0088 € / os (48,88 € / kk)
+Pov 3 julkisivu maalaus, 0,0054 € / os (30 € /kk)
+Pov 2 parkkipaikka, 0,007 € /os (38,89 € / kk)

+
+
+
+
+
+

Muut maksut

+
+
+
Vesimaksun lisätiedot
+
sisältyy vastikkeeseen
+
+
+
Muut kustannukset
+
Muut maksut: Vähintään kerran vuodessa suoritetaan tasauslaskutus todellista kulutusta vastaavaksi. +Pesutupamaksu 1 € / krt +Sähkömaksut, autosähkölataus 14,5 c / kWh +Käyttösähkö kulutuksen mukaan/ oman sähkösopimuksen mukaisesti +Kotivakuutusmaksut omien sopimusehtojen mukaisesti +Varainsiirtovero 1,5 % velattomasta kauppahinnasta + +Yhtiön osakeluettelo on siirretty Maanmittauslaitoksen huoneistotietojärjestelmään. Ostajan tulee hakea omistusoikeuden rekisteröintiä Maanmittauslaitokselta kaupan jälkeen. Ostaja vastaa omistusoikeuden rekisteröintikustannuksista, mahdollisista osakekirjan mitätöinnistä aiheutuvista kustannuksista sekä mahdollisesta hakemusmaksun korotuksesta. Ostajan pankin ostajalta mahdollisesti veloittamista omistuksen rekisteröintiin liittyvistä kuluista ostaja saa tiedon pankiltaan.
+
+ +
+
+
+

Talon ja tontin tiedot

+
+
+
Uudiskohde
+
Ei
+
+
+
Taloyhtiön nimi
+
Asunto-oy Sarvastonkaari
+
+
+
Rakennuksen tyyppi
+
Paritalo
+
+
+
Rakennusvuosi
+
1978
+
+
+
Huoneistojen lukumäärä
+
107
+
+
+
Kerroksia
+
2
+
+
+
Taloyhtiössä on sauna
+
Kyllä
+
+
+
Rakennusmateriaali
+
puu, betoni
+
+
+
Kattomateriaali
+
Peltikate
+
+
+
Kattotyyppi
+
Harjakatto
+
+
+
Energialuokka
+
D2018 Viimeinen voimassoloaika 13.8.2034 +E-luku 167
+
+
+
Energiatodistus
+
Kyllä
+
+
+
Kiinteistön antennijärjestelmä
+
kaapeli-tv
+
+
+
Tontin koko
+
3,01 ha
+
+
+
Kiinteistönhoito
+
huoltoyhtiö. Kiinteistönhoidon lisätiedot: Kotikatu Oy Laajasalo
+
+
+
Isännöinti
+
Fluxio Isännöinti Oy +Isännöitsijä Markku Laakso p. 0103390533 +asiakaspalvelu@fluxio.fi
+
+
+
Kaavoitustiedot
+
Helsingin Kaupunki, kartta.hel.fi | aineistot | kaavoitus ja liikennesuunnittelu. +Puh. 09 310 221 11
+
+
+
Kaavatilanne
+
Asemakaava
+
+
+
Lämmitys
+
Kaukolämpö
+
+
+
Lisätietoja lämmityksestä
+
Ilmanvaihtojärjestelmä: painovoimainen
+
+
+
Tontin vuokra päättyy
+
31.12.2040
+
+
+
Maanvuokraaja
+
Helsingin kaupunki
+
+
+
Tontin omistus
+
Vuokralla
+
+
+
+ +
+

Tilat ja materiaalit

+
+
+
Yhteiset tilat
+
sauna, talopesula, kuivaushuone, mankeli, askarteluhuone, Huoneistokohtaiset varastot. Taloyhtiössä muuta: Yhtiön piha-alueella on viisi pientä pihaa, joissa on lasten leikkipaikat. +Kerhotiloissa järjestetään osakkaiden toimesta; lasten taidekerho, joogaa, kuntonyrkkeilyä, kahvakuula treenejä. +Yhteisöllinen ja lapsiperheiden suosima taloyhtiö. +Helsingin Sanomien Nyt-liite palkitsi taloyhtiön pääkaupunkiseudun mukavimpana vuonna 2011. + +Yhtiöjärjestyksessä määrätään kunnossapitovastuun jakautumisesta yhtiön ja osakkeenomistajan kesken. +Osakaskohtaisten lisärakenteiden (parveke, kuistien katokset valokatteineen, terassi, jälkirakennettu parvi) kunnossapito kuuluu osakkaan vastuulle (Yj. 14 §). +Osakkaat voivat asentaa viilentävän ilmalämpöpumpun (YJ. 20 §), jota varten tehdään erillinen sopimus ja muutostyöilmoitus isännöintitoimistoon. + +Yhtiökokous päätöksiä 24.4.2025 +Taloyhtiön asentamien palovaroittimien säännöllinen testaus on osakkaiden vastuulla. +Taloyhtiö tulee liittymään Helenin Optimilämpöjärjestelmään, jos se on teknisesti mahdollista. Tavoitteena on vähentää energian kulutusta. Lisäksi taloyhtiö saa lämmitysenergiaan pienen alennuksen, kun järjestelmä on liitetty. +Todettiin, että pääomavastikelaina 2 parkkipaikka on siirretty Nordealle. + +Ylimääräisen yhtiökokouksen päätökset 21.1.2025 +Handelsbanken on ilmoittanut, että se irtisanoo yksipuolisesti taloyhtiölainat irti, tai heikentää niiden ehtoja 30.6.2025 alkaen. Hallituksen mielestä parhaan tarjouksen antoi Sarvastonkaaren ns. kotipankki Nordea, jonka lainan korko on 6kk Euribor +0,65 % marginaali. Marginaalin ensimmäinen tarkastusajankohta on kolmen (3) vuoden kuluttua. Tarjouksen marginaali on samansuuruinen kuin nykyisessä lainassa, vaikka lainasumma on pienentynyt. + +.
+
+
+
Pintamateriaalit
+
Lattia: Keittiössä: laatta. Pesutiloissa: laatta. Olohuoneessa: parketti. Makuuhuoneessa: parketti. Saunassa: laatta. Seinät: Keittiössä: maalattu. Pesutiloissa: kaakeli. Olohuoneessa: maalattu, tapetti. Makuuhuoneessa: maalattu. Saunassa: muu.
+
+
+
Olohuoneen varusteet
+
Olohuoneessa yksi seinä tapetoitu, loput maalattu. Lattia maalattu parketti.
+
+
+
+ + + +
+ + +
+
+ + + +
+
+ +
+ + +
+ +
+ + +
+
+
+
+
+ +
+
+
+
+
+ + +
+
+ +
+
+
+ + + + + + +
+ +
+
+
+ + + +
+
+
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + -- cgit v1.2.3-70-g09d2