Skill: mihaimacarie98/willhaben-realestate-search (version 1.1.0)

--- name: willhaben-realestate-search description: Search willhaben.at real estate listings (apartments, houses) via their public webapi. No browser needed. Search any Austrian state and district with price/room filters. Get full listing details including heating type, building condition, energy class (HWB/fGEE), parking, and floor. Use for Austrian property searches, willhaben real estate queries, or building property reports. Triggers on "willhaben", "Austrian real estate", "Eigentumswohnung", "Haus kaufen Österreich", property search Austria. metadata: {"openclaw":{"emoji":"🏠","requires":{"bins":["python3"]}}} --- # Willhaben Real Estate Search Search and retrieve property listings from willhaben.at using their public webapi. No browser automation needed. ## Prerequisites Requires `python3` with the `requests` library (pre-installed in most environments). ## Quick Start ### Search listings ```bash # Apartments for sale in Schärding, max €300k python3 scripts/willhaben-search.py --district schaerding --type apartment --max-price 300000 # Houses in Wels, max €350k python3 scripts/willhaben-search.py --district wels --type house --max-price 350000 # With room filter and JSON output python3 scripts/willhaben-search.py --district linz --type apartment --max-price 300000 --min-rooms 2 --format json # With full details (heating, energy, condition) for each listing python3 scripts/willhaben-search.py --district schaerding --type apartment --max-price 300000 --with-details ``` ### Get detail for a specific listing ```bash python3 scripts/willhaben-search.py --detail 1148259428 python3 scripts/willhaben-search.py --detail 1148259428 --format json ``` Detail fields include: - **HEATING** (heating type): Fernwärme, Fußbodenheizung, Zentralheizung, Gasheizung, etc. - **BUILDING_CONDITION**: Erstbezug, Sehr gut/gut, Renoviert, Renovierungsbedürftig - **YEAR_OF_BUILDING**: Construction year - **ENERGY_HWB** + **ENERGY_HWB_CLASS**: Energy rating (kWh/m²a) + class (A-G) - **ENERGY_FGEE** + **ENERGY_FGEE_CLASS**: Overall energy efficiency factor - **Parking**: Extracted from listing body text (garage, stellplatz, carport, tiefgarage) ## Property Types | CLI Type | Willhaben Category | |----------|-------------------| | `apartment` | Eigentumswohnung (buy) | | `house` | Haus kaufen | ## District Slugs Districts use willhaben's URL slug format — the slug is taken directly from the willhaben URL path. Browse `willhaben.at/iad/immobilien/` to discover slugs for any district. Examples: | District | Slug | |----------|------| | Schärding | `schaerding` | | Ried im Innkreis | `ried-im-innkreis` | | Wels (Stadt) | `wels` | | Linz (Stadt) | `linz` | | Linz-Land | `linz-land` | | Braunau am Inn | `braunau-am-inn` | | Wien | `wien` | | Graz | `graz` | | Salzburg | `salzburg-stadt` | | Innsbruck | `innsbruck` | | Klagenfurt | `klagenfurt` | Any valid willhaben district slug works — these are just examples. ## States Default state is `oberoesterreich`. Override with `--state`: ```bash python3 scripts/willhaben-search.py --state salzburg --district salzburg-stadt --type apartment ``` Available: `oberoesterreich`, `niederoesterreich`, `wien`, `salzburg`, `steiermark`, `kaernten`, `tirol`, `vorarlberg`, `burgenland` ## Parameters | Parameter | Description | |-----------|-------------| | `--district` | District URL slug (required for search) | | `--state` | Austrian state (default: `oberoesterreich`) | | `--type` | Property type: `apartment` or `house` | | `--max-price` | Maximum price in EUR | | `--min-rooms` | Minimum number of rooms | | `--max-pages` | Max result pages (default: 5) | | `--with-details` | Fetch heating/energy/condition for each listing | | `--detail` | Get full details for a specific listing ID | | `--format` | `text` or `json` | ## API Details Uses willhaben's public webapi: - Search: `GET https://www.willhaben.at/webapi/iad/search/atz/seo/immobilien/{type}/{state}/{district}` - Detail: `GET https://www.willhaben.at/webapi/iad/atverz/{ad_id}` No authentication needed. Be respectful with request rates — add delays between detail requests (~200-300ms). ## Notes - Listing URLs follow pattern: `https://www.willhaben.at/iad/{SEO_URL}` - Search returns: id, title, price, rooms, area, location, district, postcode, published date - Detail adds: heating, building condition, year built, HWB/fGEE energy ratings, parking - Parking is extracted from the listing body text when not in structured fields

path: scripts/willhaben-search.py
content: #!/usr/bin/env python3
"""
Willhaben Real Estate Search via internal webapi.
Searches apartments and houses, fetches detail pages for heating/energy/condition.

Usage:
  python3 willhaben-search.py --district schaerding --type apartment --max-price 300000
  python3 willhaben-search.py --district schaerding --type house --max-price 350000
  python3 willhaben-search.py --detail 1148259428
  python3 willhaben-search.py --district schaerding --type apartment --format json

Districts use URL slug: schaerding, ried-im-innkreis, grieskirchen, wels, wels-land,
  linz, linz-land, urfahr-umgebung, eferding, braunau-am-inn, voecklabruck
State: oberoesterreich (default), niederoesterreich, salzburg, etc.
Types: apartment (Eigentumswohnung), house (Haus kaufen)
"""
import argparse
import json
import sys
import time
import requests


API_BASE = 'https://www.willhaben.at/webapi/iad'
SEARCH_BASE = f'{API_BASE}/search/atz/seo/immobilien'
WH_HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36',
    'Accept': 'application/json',
    'Accept-Language': 'de-AT,de;q=0.9',
    'x-wh-client': '[REDACTED_SECRET];responsive_web;server;1.0.0',
}

TYPE_MAP = {
    'apartment': 'eigentumswohnung',
    'house': 'haus-kaufen',
}

# Fields to extract from detail API
DETAIL_FIELDS = [
    'HEATING', 'BUILDING_CONDITION', 'YEAR_OF_BUILDING',
    'ENERGY_HWB', 'ENERGY_HWB_CLASS', 'ENERGY_FGEE', 'ENERGY_FGEE_CLASS',
    'ESTATE_PRICE/HEATINGCOSTSNET', 'PARKING_TYPE', 'GARAGE',
    'NUMBER_OF_ROOMS', 'ESTATE_SIZE/LIVING_AREA', 'ESTATE_SIZE/USEABLE_AREA',
    'FLOOR', 'PROPERTY_TYPE_FLAT', 'PROPERTY_TYPE_HOUSE',
    'FREE_AREA/FREE_AREA_AREA_TOTAL', 'FREE_AREA_TYPE_NAME',
    'BODY_DYN',
]


def create_session():
    s = requests.Session()
    s.headers.update(WH_HEADERS)
    return s


def search_listings(session, state, district, property_type, max_price=None,
                    min_rooms=None, max_pages=5, rows_per_page=25):
    slug = TYPE_MAP.get(property_type, property_type)
    all_items = []
    total = 0

    for page in range(1, max_pages + 1):
        if district:
            url = f'{SEARCH_BASE}/{state}/{district}'
        else:
            url = f'{SEARCH_BASE}/{state}'

        params = {
            'page': page,
            'rows': rows_per_page,
        }
        if slug == 'eigentumswohnung':
            params['sfId'] = '9a285292-0c04-4a38-b1d2-68b55ec7bcde'
        if max_price:
            params['PRICE_TO'] = max_price
        if min_rooms:
            params['NO_OF_ROOMS_BUCKET'] = f'{min_rooms}X'

        # Prepend property type to URL
        url = f'{SEARCH_BASE}/{slug}/{state}'
        if district:
            url = f'{url}/{district}'

        resp = session.get(url, params=params, timeout=30)
        if resp.status_code != 200:
            print(f"  [API error page {page}: {resp.status_code}]", file=sys.stderr)
            break

        data = resp.json()
        total = data.get('rowsFound', 0)
        ads = data.get('advertSummaryList', {}).get('advertSummary', [])

        for a in ads:
            attrs = {}
            for at in a.get('attributes', {}).get('attribute', []):
                name = at.get('name', '')
                vals = at.get('values', [])
                attrs[name] = vals[0] if vals else ''

            seo_url = attrs.get('SEO_URL', '')
            full_url = f'https://www.willhaben.at/iad/{seo_url}' if seo_url else ''

            all_items.append({
                'id': str(a.get('id', '')),
                'title': a.get('description', '').replace('\n', ' ').strip(),
                'url': full_url,
                'price': attrs.get('PRICE', ''),
                'rooms': attrs.get('NUMBER_OF_ROOMS', attrs.get('ROOMS', '')),
                'area': attrs.get('ESTATE_SIZE/LIVING_AREA', attrs.get('ESTATE_SIZE', '')),
                'location': attrs.get('LOCATION', ''),
                'district': attrs.get('DISTRICT', ''),
                'postcode': attrs.get('POSTCODE', ''),
                'published': attrs.get('PUBLISHED_String', ''),
                'is_private': attrs.get('ISPRIVATE', ''),
                'property_type': attrs.get('PROPERTY_TYPE', ''),
            })

        if page * rows_per_page >= total:
            break

    return total, all_items


def get_detail(session, ad_id):
    resp = session.get(f'{API_BASE}/atverz/{ad_id}', timeout=15)
    if resp.status_code != 200:
        return {'error': f'{resp.status_code}'}

    data = resp.json()
    attrs = {}
    for at in data.get('attributes', {}).get('attribute', []):
        name = at.get('name', '')
        vals = at.get('values', [])
        attrs[name] = vals[0] if vals else ''

    result = {
        'id': ad_id,
        'heating': attrs.get('HEATING', ''),
        'condition': attrs.get('BUILDING_CONDITION', ''),
        'year': attrs.get('YEAR_OF_BUILDING', ''),
        'hwb': attrs.get('ENERGY_HWB', ''),
        'hwb_class': attrs.get('ENERGY_HWB_CLASS', ''),
        'fgee': attrs.get('ENERGY_FGEE', ''),
        'fgee_class': attrs.get('ENERGY_FGEE_CLASS', ''),
        'heating_costs': attrs.get('ESTATE_PRICE/HEATINGCOSTSNET', ''),
    }

    # Look for parking-related fields
    parking_parts = []
    for k, v in attrs.items():
        kl = k.lower()
        if v and any(w in kl for w in ['parking', 'garage', 'stellplatz', 'carport']):
            parking_parts.append(v)
    result['parking'] = ', '.join(parking_parts) if parking_parts else ''

    # Extract body text for keyword scanning
    body = attrs.get('BODY_DYN', '')
    if body and not result['heating']:
        body_l = body.lower()
        for h in ['wärmepumpe', 'fernwärme', 'pellet', 'gas', 'öl', 'elektro', 'biomasse']:
            if h in body_l:
                result['heating'] = f'(from description: {h})'
                break
    if body and not result['parking']:
        body_l = body.lower()
        for p in ['garage', 'stellplatz', 'carport', 'tiefgarage', 'parkplatz']:
            if p in body_l:
                result['parking'] = f'(from description: {p})'
                break

    return result


def main():
    parser = argparse.ArgumentParser(description='Willhaben Real Estate Search')
    parser.add_argument('--state', default='oberoesterreich',
                        help='Austrian state slug (default: oberoesterreich)')
    parser.add_argument('--district', help='District slug (e.g., schaerding, ried-im-innkreis)')
    parser.add_argument('--type', default='apartment', choices=['apartment', 'house'],
                        help='Property type')
    parser.add_argument('--max-price', type=int, help='Maximum price in EUR')
    parser.add_argument('--min-rooms', type=int, help='Minimum rooms (uses bucket: 2X, 3X, etc.)')
    parser.add_argument('--max-pages', type=int, default=5, help='Max pages (default: 5)')
    parser.add_argument('--detail', help='Get detail for a specific ad ID')
    parser.add_argument('--with-details', action='store_true',
                        help='Fetch detail page for each listing (slower but includes heating/energy)')
    parser.add_argument('--format', choices=['json', 'text'], default='text')
    args = parser.parse_args()

    session = create_session()

    if args.detail:
        result = get_detail(session, args.detail)
        if args.format == 'json':
            print(json.dumps(result, indent=2, ensure_ascii=False))
        else:
            for k, v in result.items():
                print(f'{k}: {v}')
        return

    if not args.district and not args.state:
        parser.error('--district or --state is required for search')

    total, listings = search_listings(
        session, args.state, args.district, args.type,
        args.max_price, args.min_rooms, args.max_pages
    )

    if args.with_details:
        for listing in listings:
            detail = get_detail(session, listing['id'])
            listing.update(detail)
            time.sleep(0.3)  # Be nice to the API

    if args.format == 'json':
        print(json.dumps({'total': total, 'listings': listings}, indent=2, ensure_ascii=False))
    else:
        type_label = 'Apartments' if args.type == 'apartment' else 'Houses'
        district_label = args.district or args.state
        print(f"=== {type_label} {district_label} (max {args.max_price or 'any'} EUR) ===")
        print(f"Total: {total}, fetched: {len(listings)}\n")

        for i, l in enumerate(listings, 1):
            print(f"[{i}] {l['title']}")
            print(f"    Price: {l['price']} EUR | Area: {l['area']} m² | Rooms: {l['rooms']}")
            print(f"    Location: {l['location']}, {l['district']} ({l['postcode']})")
            print(f"    Published: {l['published']}")
            if l.get('heating') or l.get('condition') or l.get('hwb'):
                print(f"    Heating: {l.get('heating', '?')} | Condition: {l.get('condition', '?')}")
                print(f"    HWB: {l.get('hwb', '?')} ({l.get('hwb_class', '?')}) | Year: {l.get('year', '?')}")
                print(f"    Parking: {l.get('parking', '?')}")
            print(f"    URL: {l['url']}")
            print()


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)