Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ python3 sources/parks.py # -> data/raw/parks.json
# tiuli (coordinates already in the pages; resumable):
python3 sources/tiuli.py # -> data/raw/tiuli.json

# kidsfun (curated indoor kids venues — geocodes sources/kidsfun_venues.json; resumable):
python3 sources/kidsfun.py # -> data/raw/kidsfun.json

# enrichment (optional but recommended; both are resumable):
python3 enrich/osm.py # -> data/enrichment/osm_pois.json (one bulk Overpass query)
python3 enrich/wikipedia.py # -> data/enrichment/wikipedia.json (~1 req/sec, ~20 min)
Expand Down Expand Up @@ -124,6 +127,15 @@ up the new source filter automatically.
present across every category, so no geocoding. Events (time-bound) and flora/fauna
(species pages) are intentionally excluded. The on-page description is templated SEO
filler, so it is left blank for Wikipedia enrichment to fill.
- **kidsfun** is not a scraper but a hand-curated list (`sources/kidsfun_venues.json`)
of indoor play venues for small kids — פעלטון-style משחקיות, ג'ימבורי, soft-play —
a category the trip-blog sources don't cover at all. Every entry carries verified
opening hours (Saturday included: these venues are typically OPEN on Shabbat, unlike
most of the corpus) and an age range in the description. `sources/kidsfun.py`
geocodes each venue with Nominatim — mall name first, then street address — rejects
hits >12km from the stated city, and caches results in
`sources/cache/kidsfun_geocode.json`. To add a venue, append it to the JSON and
re-run the script + `build.py`.
- **OSM enrichment** (`enrich/osm.py`) issues a single bulk Overpass query for named
POIs across Israel, and `build.py` copies hours/phone/website/wheelchair/fee onto a
place only on a confident name+distance match (a wrong match would show another
Expand Down
1 change: 1 addition & 0 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"coffeetrail": "Coffee Trail",
"parks": "רשות הטבע והגנים",
"tiuli": "טיולי",
"kidsfun": "בילוי מקורה לילדים",
}
COFFEE_TYPE = "עגלות קפה ופוד טראק"

Expand Down
2 changes: 1 addition & 1 deletion data/attractions.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion data/attractions.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions data/raw/kidsfun.json

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions enrich/tag_places.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

COFFEE_TYPE = "עגלות קפה ופוד טראק"
MUSEUM_TYPE = "מוזיאון, אתר מורשת, מרכז מבקרים ועתיקות"
KIDSFUN_TYPE = "משחקיות ובילוי מקורה" # sources/kidsfun.py curated venues
NATURE_TYPES = ["טיולי פריחה", "נקודות עניין בטבע", "שמורות טבע וגנים לאומיים",
"נקודות תצפית", "פארקים לפיקניק", "מסלולי טיול"]
KIDS_TYPES = ["פארק שעשועים", "טיולים עם עגלות", "טיולים עם חיות"]
Expand All @@ -36,8 +37,9 @@
r"מוזיאון|מרכז מדע|מדע וטכנולוגיה|מרכז מבקרים|מרכז המבקרים|משחקיי|משחקיה|"
r"אסקייפ|escape|חדר בריחה|חדרי בריחה|טרמפולינ|ג'ימבו|ג׳ימבו|באולינג|"
r"פלנטריום|פלנת|גלריה|לייזר|נינג'ה|נינג׳ה|אקווריום|מצפה כוכבים|"
r"חלל המופלא|אורבניה|בית הראשונים|יקב|קיר טיפוס|תיאטרון|סינמה|קולנוע")
KIDS_RE = re.compile(r"טרמפולינ|ג'ימבו|ג׳ימבו|משחקיי|משחקיה|ילדים|משפח|שעשוע")
r"חלל המופלא|אורבניה|בית הראשונים|יקב|קיר טיפוס|תיאטרון|סינמה|קולנוע|"
r"פעלטון|לונדע|סופט פליי|ג'ימבורי|ג׳ימבורי")
KIDS_RE = re.compile(r"טרמפולינ|ג'ימבו|ג׳ימבו|משחקיי|משחקיה|ילדים|משפח|שעשוע|פעלטון|לונדע")
BEACH_RE = re.compile(r"חוף|טיילת")


Expand All @@ -48,10 +50,10 @@ def rule_tags(a):
hay = (a.get("title", "") + " " + " ".join(a.get("keywords", [])) + " " + " ".join(types))
tags = set()
is_coffee = COFFEE_TYPE in types
is_indoor = MUSEUM_TYPE in types or bool(INDOOR_RE.search(hay))
is_indoor = MUSEUM_TYPE in types or KIDSFUN_TYPE in types or bool(INDOOR_RE.search(hay))
is_beach = "טיולי מים" in types or bool(BEACH_RE.search(hay))
is_nature = any(t in types for t in NATURE_TYPES)
is_kids = any(t in types for t in KIDS_TYPES) or bool(KIDS_RE.search(hay))
is_kids = any(t in types for t in KIDS_TYPES) or KIDSFUN_TYPE in types or bool(KIDS_RE.search(hay))

if is_coffee:
tags |= {"outdoor", "food", "quick-stop"}
Expand Down
1 change: 1 addition & 0 deletions sources/cache/kidsfun_geocode.json

Large diffs are not rendered by default.

163 changes: 163 additions & 0 deletions sources/kidsfun.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Curated source: indoor play venues for small kids (פעלטון-style משחקיות,
ג'ימבורי, soft-play), hand-maintained in sources/kidsfun_venues.json.

Unlike the scraped sources, this one is a verified editorial list: every venue
carries opening hours (Saturday matters most — these are the places that ARE
open on Shabbat when the heritage/nature corpus is closed) and an age range.
Coordinates are geocoded via Nominatim (mall name first, then street address),
cached in sources/cache/kidsfun_geocode.json, and sanity-checked against the
venue's city centroid so a bad geocode can't land a branch in the wrong town.

Emits the shared raw schema to data/raw/kidsfun.json with hours/phone/website
passed through (build.py's merge copies those fields from raw members).
"""
import json
import os
import re
import time
import urllib.parse
import urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
VENUES = os.path.join(HERE, "kidsfun_venues.json")
CACHE = os.path.join(HERE, "cache", "kidsfun_geocode.json")
OUT = os.path.join(ROOT, "data", "raw", "kidsfun.json")

UA = "attractions-map-builder/1.0 (amara.nir@gmail.com)"
LAT_MIN, LAT_MAX = 29.45, 33.40
LON_MIN, LON_MAX = 34.20, 35.95
KIDSFUN_TYPE = "משחקיות ובילוי מקורה"

_last_req = [0.0]


def _nominatim(params):
wait = 1.1 - (time.time() - _last_req[0])
if wait > 0:
time.sleep(wait)
url = "https://nominatim.openstreetmap.org/search?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
data = json.load(r)
_last_req[0] = time.time()
return data


def geocode(query):
# Two attempts: countrycodes=il first, then a bounded viewbox without it —
# West Bank places (e.g. Maale Adumim) aren't tagged 'il' in Nominatim.
for params in (
{"q": query, "countrycodes": "il", "format": "json", "limit": "1",
"viewbox": f"{LON_MIN},{LAT_MAX},{LON_MAX},{LAT_MIN}"},
{"q": query, "format": "json", "limit": "1", "bounded": "1",
"viewbox": f"{LON_MIN},{LAT_MAX},{LON_MAX},{LAT_MIN}"},
):
data = _nominatim(params)
if not data:
continue
lat, lon = float(data[0]["lat"]), float(data[0]["lon"])
if LAT_MIN <= lat <= LAT_MAX and LON_MIN <= lon <= LON_MAX:
return {"lat": lat, "lng": lon, "display": data[0].get("display_name", "")}
return None


def geocode_city(city):
data = _nominatim({
"city": city, "format": "json", "limit": "1",
"viewbox": f"{LON_MIN},{LAT_MAX},{LON_MAX},{LAT_MIN}", "bounded": "1",
})
if not data:
return None
return {"lat": float(data[0]["lat"]), "lng": float(data[0]["lon"])}


def dist_km(a, b):
import math
dlat = (a["lat"] - b["lat"]) * 111.0
dlng = (a["lng"] - b["lng"]) * 111.0 * math.cos(math.radians((a["lat"] + b["lat"]) / 2))
return math.hypot(dlat, dlng)


def load_cache():
if os.path.exists(CACHE):
return json.load(open(CACHE, encoding="utf-8"))
return {}


def main():
venues = json.load(open(VENUES, encoding="utf-8"))
cache = load_cache()

def cached(key, fn, *args):
if key not in cache:
try:
cache[key] = fn(*args)
except Exception:
cache[key] = None
return cache[key]

out, misses = [], []
for v in venues:
city = v["city"]
# Mall name resolves to the exact building; the street address and the
# venue name are progressively weaker fallbacks.
queries = []
if v.get("geocode_hint"):
queries.append(v["geocode_hint"])
if v.get("mall"):
queries.append(f"{v['mall']}, {city}")
if v.get("address"):
queries.append(f"{v['address']}, {city}")
queries.append(f"{v['title']}, {city}")

geo = None
for q in queries:
geo = cached(q, geocode, q)
if not geo:
continue
centroid = cached("city:" + city, geocode_city, city)
# Reject hits far from the stated city (same-named mall/street in
# another town); 12km covers metro sprawl like Beer Sheva.
if centroid and dist_km(geo, centroid) > 12:
geo = None
continue
break
if not geo:
misses.append(v["title"])
continue

hours = v.get("hours")
keywords = ["משחקייה", "ילדים", "בילוי מקורה"] + v.get("keywords", [])
# "Sa <time>" (not "Sa off") ⇒ open on Shabbat — the killer search term.
if hours and re.search(r"Sa[^;]*\d", hours) and not re.search(r"Sa (off|closed)", hours):
keywords.append("פתוח בשבת")

out.append({
"source": "kidsfun",
"title": v["title"],
"lat": round(geo["lat"], 6),
"lng": round(geo["lng"], 6),
"region": None, # build.py derives from coords
"types": [KIDSFUN_TYPE],
"keywords": keywords,
"image": v.get("image"),
"url": v["url"],
"address": f"{v['address']}, {city}" if v.get("address") else city,
"description": v.get("description"),
"hours": v.get("hours"),
"phone": v.get("phone"),
"website": v.get("website") or v["url"],
})

json.dump(cache, open(CACHE, "w", encoding="utf-8"), ensure_ascii=False)
os.makedirs(os.path.dirname(OUT), exist_ok=True)
json.dump(out, open(OUT, "w", encoding="utf-8"), ensure_ascii=False)
print(f"kidsfun: {len(out)} venues placed -> {OUT}")
if misses:
print(f" {len(misses)} could not be geocoded: " + "; ".join(misses))


if __name__ == "__main__":
main()
Loading