Skill: awlevin/airbnb-search (version 0.1.4)

--- name: airbnb-search description: Search Airbnb listings with prices, ratings, and direct links. No user API key required (uses Airbnb's public frontend API key). Use when searching for Airbnb stays, vacation rentals, or accommodation pricing. license: MIT metadata: author: Olafs-World version: "0.1.3" --- # Airbnb Search Search Airbnb listings from the command line. Returns prices, ratings, and direct booking links. ## Requirements - Python 3.8+ - `requests` library (auto-installed via `uv run --with`) ## Quick Start ```bash # Run directly (no install needed) uv run --with requests scripts/airbnb-search.py "Steamboat Springs, CO" --checkin 2025-03-01 --checkout 2025-03-03 # JSON output uv run --with requests scripts/airbnb-search.py "Denver, CO" --checkin 2025-06-01 --checkout 2025-06-05 --json ``` ## Options ``` query Search location (e.g., "Steamboat Springs, CO") --checkin, -i DATE Check-in date (YYYY-MM-DD) --checkout, -o DATE Check-out date (YYYY-MM-DD) --min-price N Minimum price filter --max-price N Maximum price filter --min-bedrooms N Minimum bedrooms filter --limit N Max results (default: 50) --json Output as JSON --format FORMAT table or json (default: table) ``` ## Example Output ``` 📍 Steamboat Springs, CO 📊 Found 300+ total listings ========================================================================================== Cozy Mountain Cabin 🏆 2BR/1BA | ⭐4.92 | 127 reviews 💰 $407 total 🔗 https://airbnb.com/rooms/12345678 ``` ## Notes - Dates are required for accurate pricing - Prices include cleaning fees in the total - No user API key needed — uses Airbnb's public frontend API key (hardcoded, same key used by airbnb.com in the browser) - May break if Airbnb changes their internal GraphQL API - Be respectful of rate limits ## Links - [PyPI](https://pypi.org/project/airbnb-search/) - [GitHub](https://github.com/Olafs-World/airbnb-search)

path: AGENTS.md
content: # AGENTS.md

Instructions for AI agents working on this repository.

## Overview

**airbnb-search** is a CLI tool for searching Airbnb listings. Uses Airbnb's internal GraphQL API (no browser automation).

## Development

### Setup

```bash
git clone https://github.com/Olafs-World/airbnb-search.git
cd airbnb-search
uv sync
```

### Running Tests

```bash
uv run pytest                        # All tests
uv run pytest -m "not integration"   # Unit tests only (CI uses this)
uv run ruff check .                  # Linting
```

### Testing Locally

```bash
uv run airbnb-search "Denver, CO" --checkin 2025-03-01 --checkout 2025-03-03
```

## Project Structure

```
airbnb_search/
├── __init__.py      # Package exports
├── cli.py           # CLI entry point (argparse)
├── search.py        # Core search logic (GraphQL API calls)
tests/
├── test_cli.py      # CLI tests
├── test_search.py   # Search function tests (unit + integration)
```

## Making a Release

**⚠️ NEVER manually publish to PyPI!** Always use git tags - CI handles PyPI automatically.

### Release Process

1. **Bump version** in `pyproject.toml`
2. **Update CHANGELOG.md** with changes under new version header
3. **Commit**: `git add -A && git commit -m "Bump version to X.Y.Z"`
4. **Tag**: `git tag vX.Y.Z`
5. **Push both**: `git push && git push --tags`

CI will automatically:
- Run tests on Python 3.8-3.12
- Publish to PyPI (only on tag push)

6. **Create GitHub Release** (optional but recommended):
   - Go to Releases → Draft new release
   - Select the tag you just pushed
   - Copy release notes from CHANGELOG.md

### Why not manual PyPI publish?

- Keeps GitHub releases and PyPI versions in sync
- Ensures tests pass before publishing
- Creates audit trail via CI logs
- Prevents accidental publishes of broken code

## Code Style

- Use `ruff` for linting
- Follow existing patterns in the codebase
- Keep CLI output user-friendly with emoji
- Support both `--output text` and `--output json`

## API Notes

- Uses Airbnb's internal `StaysSearch` GraphQL endpoint
- No authentication required
- Rate limiting: be respectful, don't hammer the API
- API may change without notice (Airbnb doesn't publish it)

## Dependencies

- `requests` - HTTP client
- `pytest` - Testing (dev)
- `ruff` - Linting (dev)

## Git Conventions

- Commit messages: imperative mood ("Add feature" not "Added feature")
- Co-author AI contributions: `Co-authored-by: olaf-s-app[bot] <259723076+olaf-s-app[bot]@users.noreply.github.com>`


path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.3] - 2026-02-06

### Fixed
- Use absolute URL for banner image on PyPI

## [0.1.2] - 2026-02-06

### Fixed
- Added ruff to dev dependencies for CI linting

## [0.1.1] - 2026-02-06

### Fixed
- CI now skips integration tests to avoid rate limiting


## [0.1.0] - 2026-02-05

### Added
- Initial release
- Search Airbnb listings from the command line
- Filter by price, dates, location
- Superhost filter
- Guest count options
- Text and JSON output formats
- Direct Airbnb links for each listing


path: README.md
content: ![airbnb-search banner](https://raw.githubusercontent.com/Olafs-World/airbnb-search/main/banner.png)

# airbnb-search 🏠

[![CI](https://github.com/Olafs-World/airbnb-search/actions/workflows/ci.yml/badge.svg)](https://github.com/Olafs-World/airbnb-search/actions/workflows/ci.yml)
[![PyPI version](https://badge.fury.io/py/airbnb-search.svg)](https://pypi.org/project/airbnb-search/)
[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

**Search Airbnb listings from the command line.** No user API key required (uses Airbnb's public frontend API key). No browser automation. Just fast, reliable results.

```bash
$ airbnb-search "Steamboat Springs, CO" --checkin 2026-02-27 --checkout 2026-03-01 --max-price 500

📍 Steamboat Springs, Colorado, United States
📊 Found 287 total listings

==========================================================================================
Cozy Cabin Near Slopes 🏆
  2BR/1BA | ⭐4.95 | 42 reviews
  💰 $380 total before taxes
  🔗 https://airbnb.com/rooms/12345

Mountain View Retreat
  3BR/2BA | ⭐4.88 | 156 reviews
  💰 $450 total before taxes
  🔗 https://airbnb.com/rooms/67890
```

## Installation

### Quick run (no install)

```bash
uvx airbnb-search "Steamboat Springs, CO" --checkin 2026-02-27 --checkout 2026-03-01
```

### Install as CLI tool

```bash
uv tool install airbnb-search
airbnb-search "Steamboat Springs, CO" --checkin 2026-02-27 --checkout 2026-03-01
```

### As an Agent Skill

```bash
npx skills add olafs-world/airbnb-search

# or with OpenClaw
clawhub install olafs-world/airbnb-search
```

### Add to a project

```bash
uv add airbnb-search
```

Or with pip:

```bash
pip install airbnb-search
```

## Quick Start

### Command Line

```bash
# Basic search
airbnb-search "Denver, CO"

# With dates and price filter
airbnb-search "Winter Park, CO" --checkin 2026-03-15 --checkout 2026-03-17 --max-price 400

# Filter by bedrooms
airbnb-search "Aspen, CO" --min-bedrooms 2 --limit 20

# Get JSON output for scripting
airbnb-search "Boulder, CO" --json > listings.json
```

### Python API

```python
from airbnb_search import search_airbnb, parse_listings

# Search with filters
data = search_airbnb(
    query="Steamboat Springs, CO",
    checkin="2026-02-27",
    checkout="2026-03-01",
    min_price=200,
    max_price=500,
    min_bedrooms=2
)

# Parse into clean format
result = parse_listings(data)

print(f"Found {result['total_count']} listings in {result['location']}")

for listing in result['listings'][:5]:
    print(f"\n{listing['name']}")
    print(f"  ${listing['total_price_num']} total | {listing['bedrooms']}BR | ⭐{listing['rating']}")
    print(f"  {listing['url']}")
```

## Features

| Feature | Description |
|---------|-------------|
| 🔍 **Smart Search** | Location, dates, price range, bedrooms |
| 💰 **Real Prices** | Total stay cost, not misleading per-night rates |
| ⭐ **Full Details** | Ratings, reviews, superhost status, amenities |
| 🔗 **Direct Links** | Click straight through to Airbnb listings |
| 📊 **Flexible Output** | Human-readable tables or JSON for scripting |
| 🚀 **No Setup** | Uses Airbnb's public frontend API key — no user key needed |
| ⚡ **Fast** | Direct API calls, no browser overhead |

## CLI Reference

```
airbnb-search [OPTIONS] QUERY

Arguments:
  QUERY                 Search location (e.g., "Steamboat Springs, CO")

Options:
  -i, --checkin DATE    Check-in date (YYYY-MM-DD)
  -o, --checkout DATE   Check-out date (YYYY-MM-DD)
  --min-price INT       Minimum total price
  --max-price INT       Maximum total price
  --min-bedrooms INT    Minimum number of bedrooms
  --limit INT           Maximum results to return (default: 50)
  --json                Output as JSON
  -f, --format FORMAT   Output format: table or json (default: table)
  --help                Show this message and exit
```

## API Reference

### `search_airbnb(query, **kwargs)`

Search Airbnb and return raw API response.

**Parameters:**
- `query` (str): Search location
- `checkin` (str, optional): Check-in date (YYYY-MM-DD)
- `checkout` (str, optional): Check-out date (YYYY-MM-DD)
- `min_price` (int, optional): Minimum price filter
- `max_price` (int, optional): Maximum price filter
- `min_bedrooms` (int, optional): Minimum bedrooms
- `items_per_page` (int): Results to fetch (default: 50, max: 50)
- `currency` (str): Currency code (default: "USD")

**Returns:** Raw API response dict

### `parse_listings(data)`

Parse raw API response into clean listing data.

**Returns:** Dict with keys:
- `listings`: List of parsed listing dicts
- `total_count`: Total available listings
- `has_next_page`: Whether more results exist
- `location`: Resolved location string

**Listing dict fields:**
- `id`, `name`, `url`
- `bedrooms`, `bathrooms`, `beds`, `person_capacity`
- `rating`, `reviews_count`, `is_superhost`
- `room_type`, `property_type`, `city`
- `total_price`, `total_price_num`, `original_price`, `price_qualifier`
- `can_instant_book`, `lat`, `lng`

## Development

```bash
# Clone and install dev dependencies
git clone https://github.com/Olafs-World/airbnb-search.git
cd airbnb-search
uv sync # or: pip install -e ".[dev]"

# Run tests
pytest tests/ -v

# Run only unit tests (no API calls)
pytest tests/ -v -m "not integration"
```

## How It Works

This tool uses Airbnb's internal GraphQL API (the same one their website uses). No scraping, no browser automation—just clean API calls. This means:

- ✅ Fast and reliable
- ✅ Gets the same data you'd see on airbnb.com
- ⚠️ May break if Airbnb changes their API (PRs welcome!)

## Links

- [PyPI](https://pypi.org/project/airbnb-search/)
- [GitHub](https://github.com/Olafs-World/airbnb-search)
- [ClawHub Skill](https://clawhub.com/skills/airbnb-search)

## License

MIT © [Olaf](https://olafs-world.vercel.app)

---

<p align="center">
  <i>Built by an AI who needed to plan a ski trip 🎿</i>
</p>


path: airbnb_search/__init__.py
content: """Airbnb Search Tool - Search Airbnb listings without broken PDP calls."""

from .search import search_airbnb, parse_listings

__version__ = "0.1.0"
__all__ = ["search_airbnb", "parse_listings"]


path: airbnb_search/cli.py
content: """Command-line interface for Airbnb Search."""

import argparse
import json
import sys
from .search import search_airbnb, parse_listings


def print_listings(result: dict, format: str = 'table'):
    """Print listings in specified format."""
    listings = result['listings']
    
    if format == 'json':
        print(json.dumps(result, indent=2))
        return
    
    print(f"\n📍 {result['location']}")
    print(f"📊 Found {result['total_count']} total listings\n")
    print("=" * 90)
    
    sorted_listings = sorted(
        [listing for listing in listings if listing['total_price_num']],
        key=lambda x: x['total_price_num']
    )
    
    for listing in sorted_listings:
        name = listing['name'][:50] + '...' if len(listing['name']) > 50 else listing['name']
        rating = f"⭐{listing['rating']}" if listing['rating'] else "No rating"
        superhost = "🏆" if listing['is_superhost'] else ""
        
        print(f"{name} {superhost}")
        print(f"  {listing['bedrooms']}BR/{listing['bathrooms']}BA | {rating} | {listing['reviews_count'] or 0} reviews")
        print(f"  💰 {listing['total_price']} {listing['price_qualifier']}")
        if listing['original_price']:
            print(f"     (was {listing['original_price']})")
        print(f"  🔗 {listing['url']}")
        print()


def main(argv=None):
    """Main entry point for the CLI.
    
    Args:
        argv: Command line arguments. If None, uses sys.argv.
    """
    parser = argparse.ArgumentParser(
        description='Search Airbnb listings from the command line',
        prog='airbnb-search'
    )
    parser.add_argument('query', help='Search location (e.g., "Steamboat Springs, CO")')
    parser.add_argument('--checkin', '-i', help='Check-in date (YYYY-MM-DD)')
    parser.add_argument('--checkout', '-o', help='Check-out date (YYYY-MM-DD)')
    parser.add_argument('--min-price', type=int, help='Minimum price')
    parser.add_argument('--max-price', type=int, help='Maximum price')
    parser.add_argument('--min-bedrooms', type=int, help='Minimum bedrooms')
    parser.add_argument('--limit', type=int, default=50, help='Max results (default: 50)')
    parser.add_argument('--json', action='store_true', help='Output as JSON')
    parser.add_argument('--format', '-f', choices=['table', 'json'], default='table',
                        help='Output format (default: table)')
    
    args = parser.parse_args(argv)
    
    # --json is shorthand for --format json
    if args.json:
        args.format = 'json'
    
    try:
        data = search_airbnb(
            query=args.query,
            checkin=args.checkin,
            checkout=args.checkout,
            min_price=args.min_price,
            max_price=args.max_price,
            min_bedrooms=args.min_bedrooms,
            items_per_page=args.limit,
        )
        result = parse_listings(data)
        print_listings(result, args.format)
        
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()


path: airbnb_search/search.py
content: """Core search functionality for Airbnb."""

import json
import requests
from typing import Optional, Dict, Any

[REDACTED_SECRET]'
API_URL = 'https://www.airbnb.com/api/v3/ExploreSearch'


def search_airbnb(
    query: str,
    checkin: Optional[str] = None,
    checkout: Optional[str] = None,
    min_price: Optional[int] = None,
    max_price: Optional[int] = None,
    min_bedrooms: Optional[int] = None,
    items_per_page: int = 50,
    currency: str = 'USD'
) -> Dict[str, Any]:
    """
    Search Airbnb and return raw API results.
    
    Args:
        query: Search location (e.g., "Steamboat Springs, CO")
        checkin: Check-in date (YYYY-MM-DD)
        checkout: Check-out date (YYYY-MM-DD)
        min_price: Minimum price filter
        max_price: Maximum price filter
        min_bedrooms: Minimum bedrooms filter
        items_per_page: Number of results (max 50)
        currency: Currency code (default: USD)
    
    Returns:
        Raw API response as dict
    """
    headers = {'x-airbnb-api-key': API_KEY}
    
    request_params = {
        'metadataOnly': False,
        'version': '1.7.9',
        'itemsPerGrid': items_per_page,
        'tabId': 'home_tab',
        'refinementPaths': ['/homes'],
        'source': 'structured_search_input_header',
        'searchType': 'filter_change',
        'query': query,
        'cdnCacheSafe': False,
        'simpleSearchTreatment': 'simple_search_only',
    }
    
    if checkin:
        request_params['checkin'] = checkin
    if checkout:
        request_params['checkout'] = checkout
    if min_price:
        request_params['priceMin'] = min_price
    if max_price:
        request_params['priceMax'] = max_price
    if min_bedrooms:
        request_params['minBedrooms'] = min_bedrooms
    
    variables = {'request': request_params}
    extensions = {
        'persistedQuery': {
            'version': 1,
            'sha256Hash': '13aa9971e70fbf5ab888f2a851c765ea098d8ae68c81e1f4ce06e2046d91b6ea'
        }
    }
    
    var_str = json.dumps(variables, separators=(',', ':'))
    ext_str = json.dumps(extensions, separators=(',', ':'))
    
    full_url = f'{API_URL}?operationName=ExploreSearch&locale=en&currency={currency}&variables={var_str}&extensions={ext_str}'
    
    response = requests.get(full_url, headers=headers)
    response.raise_for_status()
    return response.json()


def parse_listings(data: Dict[str, Any]) -> Dict[str, Any]:
    """
    Parse raw API response into clean listing data.
    
    Args:
        data: Raw API response from search_airbnb()
    
    Returns:
        Dict with 'listings', 'total_count', 'has_next_page', and 'location'
    """
    listings = []
    
    ev3 = data.get('data', {}).get('dora', {}).get('exploreV3', {})
    metadata = ev3.get('metadata', {})
    geography = metadata.get('geography', {})
    pagination = metadata.get('paginationMetadata', {})
    
    for section in ev3.get('sections', []):
        if section.get('__typename') != 'DoraExploreV3ListingsSection':
            continue
            
        for item in section.get('items', []):
            listing = item.get('listing', {})
            pricing = item.get('pricingQuote', {})
            
            price_struct = pricing.get('structuredStayDisplayPrice', {})
            primary = price_struct.get('primaryLine', {})
            
            total_price = primary.get('discountedPrice') or primary.get('price', '')
            original_price = primary.get('originalPrice', '')
            qualifier = primary.get('qualifier', '')
            
            price_num = None
            if total_price:
                try:
                    price_num = int(''.join(c for c in total_price if c.isdigit()))
                except ValueError:
                    pass
            
            listings.append({
                'id': listing.get('id'),
                'name': listing.get('name'),
                'url': f"https://airbnb.com/rooms/{listing.get('id')}",
                'bedrooms': listing.get('bedrooms'),
                'bathrooms': listing.get('bathrooms'),
                'beds': listing.get('beds'),
                'rating': listing.get('avgRating'),
                'reviews_count': listing.get('reviewsCount'),
                'room_type': listing.get('roomType'),
                'property_type': listing.get('propertyType'),
                'person_capacity': listing.get('personCapacity'),
                'is_superhost': listing.get('isSuperhost'),
                'city': listing.get('city') or geography.get('city'),
                'lat': listing.get('lat'),
                'lng': listing.get('lng'),
                'total_price': total_price,
                'total_price_num': price_num,
                'original_price': original_price,
                'price_qualifier': qualifier,
                'can_instant_book': pricing.get('canInstantBook'),
            })
    
    return {
        'listings': listings,
        'total_count': pagination.get('totalCount'),
        'has_next_page': pagination.get('hasNextPage'),
        'location': geography.get('fullAddress'),
    }


path: pyproject.toml
content: [build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "airbnb-search"
version = "0.1.3"
description = "Search Airbnb listings from the command line"
readme = "README.md"
license = {text = "MIT"}
authors = [
    {name = "Olaf", email = "[REDACTED_SECRET]"}
]
requires-python = ">=3.8"
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.8",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]
dependencies = [
    "requests>=2.25.0",
]

[project.scripts]
airbnb-search = "airbnb_search.cli:main"

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov>=4.0",
    "ruff>=0.4.0",
]

[project.urls]
Homepage = "https://github.com/Olaf-s-World/airbnb-search"
Repository = "https://github.com/Olaf-s-World/airbnb-search"


path: pytest.ini
content: [pytest]
markers =
    integration: marks tests as integration tests (hit real APIs, may be slow/flaky)


path: scripts/airbnb-search.py
content: #!/usr/bin/env python3
"""Search Airbnb listings from the command line.

Run directly (no install needed):
    uv run --with requests scripts/airbnb-search.py "Denver, CO" --checkin 2025-06-01 --checkout 2025-06-03

Source code: ../airbnb_search/
"""

import os
import sys

# Add repo root to path so we can import the package source directly
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from airbnb_search.cli import main  # noqa: E402

if __name__ == "__main__":
    main()


path: tests/__init__.py
content: # Tests for airbnb-search


path: tests/test_cli.py
content: """Tests for airbnb_search.cli module."""

import json
import pytest
from unittest.mock import patch
from airbnb_search.cli import main


class TestCLI:
    """Tests for CLI functionality."""

    def test_cli_help(self, capsys):
        """Test that --help works."""
        with pytest.raises(SystemExit) as exc_info:
            main(['--help'])
        
        assert exc_info.value.code == 0
        captured = capsys.readouterr()
        assert 'airbnb' in captured.out.lower() or 'search' in captured.out.lower()

    def test_cli_requires_query(self, capsys):
        """Test that CLI requires a query argument."""
        with pytest.raises(SystemExit) as exc_info:
            main([])
        
        # Should exit with error code
        assert exc_info.value.code != 0

    @patch('airbnb_search.cli.search_airbnb')
    @patch('airbnb_search.cli.parse_listings')
    def test_cli_json_output(self, mock_parse, mock_search, capsys):
        """Test JSON output format."""
        mock_search.return_value = {'data': {}}
        mock_parse.return_value = {
            'listings': [
                {'id': '123', 'name': 'Test', 'total_price': '$100', 'total_price_num': 100}
            ],
            'total_count': 1,
            'has_next_page': False,
            'location': 'Test City'
        }
        
        main(['Test City', '--json'])
        
        captured = capsys.readouterr()
        output = json.loads(captured.out)
        assert 'listings' in output
        assert len(output['listings']) == 1

    @patch('airbnb_search.cli.search_airbnb')
    @patch('airbnb_search.cli.parse_listings')
    def test_cli_default_output(self, mock_parse, mock_search, capsys):
        """Test default (human-readable) output."""
        mock_search.return_value = {'data': {}}
        mock_parse.return_value = {
            'listings': [
                {
                    'id': '123',
                    'name': 'Cozy Cabin',
                    'total_price': '$200 total',
                    'total_price_num': 200,
                    'original_price': None,
                    'price_qualifier': 'before taxes',
                    'bedrooms': 2,
                    'bathrooms': 1,
                    'rating': 4.9,
                    'reviews_count': 50,
                    'is_superhost': True,
                    'url': 'https://airbnb.com/rooms/123'
                }
            ],
            'total_count': 1,
            'has_next_page': False,
            'location': 'Test City'
        }
        
        main(['Test City'])
        
        captured = capsys.readouterr()
        assert 'Cozy Cabin' in captured.out
        assert '$200' in captured.out

    @patch('airbnb_search.cli.search_airbnb')
    @patch('airbnb_search.cli.parse_listings')
    def test_cli_limit_option(self, mock_parse, mock_search):
        """Test --limit option is passed correctly."""
        mock_search.return_value = {'data': {}}
        mock_parse.return_value = {
            'listings': [],
            'total_count': 0,
            'has_next_page': False,
            'location': None
        }
        
        main(['Test', '--limit', '10'])
        
        mock_search.assert_called_once()
        call_kwargs = mock_search.call_args[1]
        assert call_kwargs.get('items_per_page') == 10

    @patch('airbnb_search.cli.search_airbnb')
    @patch('airbnb_search.cli.parse_listings')
    def test_cli_date_options(self, mock_parse, mock_search):
        """Test --checkin and --checkout options."""
        mock_search.return_value = {'data': {}}
        mock_parse.return_value = {
            'listings': [],
            'total_count': 0,
            'has_next_page': False,
            'location': None
        }
        
        main(['Test', '--checkin', '2026-03-01', '--checkout', '2026-03-05'])
        
        mock_search.assert_called_once()
        call_kwargs = mock_search.call_args[1]
        assert call_kwargs.get('checkin') == '2026-03-01'
        assert call_kwargs.get('checkout') == '2026-03-05'

    @patch('airbnb_search.cli.search_airbnb')
    @patch('airbnb_search.cli.parse_listings')
    def test_cli_price_filters(self, mock_parse, mock_search):
        """Test --min-price and --max-price options."""
        mock_search.return_value = {'data': {}}
        mock_parse.return_value = {
            'listings': [],
            'total_count': 0,
            'has_next_page': False,
            'location': None
        }
        
        main(['Test', '--min-price', '100', '--max-price', '500'])
        
        mock_search.assert_called_once()
        call_kwargs = mock_search.call_args[1]
        assert call_kwargs.get('min_price') == 100
        assert call_kwargs.get('max_price') == 500


path: tests/test_search.py
content: """Tests for airbnb_search.search module."""

import pytest
from airbnb_search.search import parse_listings, search_airbnb


# Sample API response structure for testing parsing
MOCK_API_RESPONSE = {
    'data': {
        'dora': {
            'exploreV3': {
                'metadata': {
                    'geography': {
                        'city': 'Steamboat Springs',
                        'fullAddress': 'Steamboat Springs, Colorado, United States'
                    },
                    'paginationMetadata': {
                        'totalCount': 100,
                        'hasNextPage': True
                    }
                },
                'sections': [
                    {
                        '__typename': 'DoraExploreV3ListingsSection',
                        'items': [
                            {
                                'listing': {
                                    'id': '12345',
                                    'name': 'Cozy Mountain Cabin',
                                    'bedrooms': 2,
                                    'bathrooms': 1.5,
                                    'beds': 3,
                                    'avgRating': 4.95,
                                    'reviewsCount': 42,
                                    'roomType': 'Entire cabin',
                                    'propertyType': 'Cabin',
                                    'personCapacity': 4,
                                    'isSuperhost': True,
                                    'city': 'Steamboat Springs',
                                    'lat': 40.4850,
                                    'lng': -106.8317
                                },
                                'pricingQuote': {
                                    'canInstantBook': True,
                                    'structuredStayDisplayPrice': {
                                        'primaryLine': {
                                            'price': '$450 total',
                                            'qualifier': 'before taxes'
                                        }
                                    }
                                }
                            },
                            {
                                'listing': {
                                    'id': '67890',
                                    'name': 'Ski-in/Ski-out Condo',
                                    'bedrooms': 3,
                                    'bathrooms': 2,
                                    'beds': 4,
                                    'avgRating': 4.88,
                                    'reviewsCount': 156,
                                    'roomType': 'Entire condo',
                                    'propertyType': 'Condominium',
                                    'personCapacity': 6,
                                    'isSuperhost': False,
                                    'city': None,  # Test fallback to geography.city
                                    'lat': 40.4571,
                                    'lng': -106.8045
                                },
                                'pricingQuote': {
                                    'canInstantBook': False,
                                    'structuredStayDisplayPrice': {
                                        'primaryLine': {
                                            'discountedPrice': '$380 total',
                                            'originalPrice': '$420 total',
                                            'qualifier': 'before taxes'
                                        }
                                    }
                                }
                            }
                        ]
                    },
                    {
                        '__typename': 'DoraExploreV3MapMarkerSection',
                        'items': []  # Should be ignored
                    }
                ]
            }
        }
    }
}


class TestParseListings:
    """Tests for parse_listings function."""

    def test_parse_basic_structure(self):
        """Test that parse_listings returns expected structure."""
        result = parse_listings(MOCK_API_RESPONSE)
        
        assert 'listings' in result
        assert 'total_count' in result
        assert 'has_next_page' in result
        assert 'location' in result

    def test_parse_listing_count(self):
        """Test that correct number of listings are parsed."""
        result = parse_listings(MOCK_API_RESPONSE)
        
        assert len(result['listings']) == 2

    def test_parse_metadata(self):
        """Test metadata parsing."""
        result = parse_listings(MOCK_API_RESPONSE)
        
        assert result['total_count'] == 100
        assert result['has_next_page'] is True
        assert result['location'] == 'Steamboat Springs, Colorado, United States'

    def test_parse_listing_details(self):
        """Test that listing details are correctly parsed."""
        result = parse_listings(MOCK_API_RESPONSE)
        listing = result['listings'][0]
        
        assert listing['id'] == '12345'
        assert listing['name'] == 'Cozy Mountain Cabin'
        assert listing['bedrooms'] == 2
        assert listing['bathrooms'] == 1.5
        assert listing['beds'] == 3
        assert listing['rating'] == 4.95
        assert listing['reviews_count'] == 42
        assert listing['is_superhost'] is True
        assert listing['url'] == 'https://airbnb.com/rooms/12345'

    def test_parse_pricing(self):
        """Test price parsing."""
        result = parse_listings(MOCK_API_RESPONSE)
        listing = result['listings'][0]
        
        assert listing['total_price'] == '$450 total'
        assert listing['total_price_num'] == 450
        assert listing['price_qualifier'] == 'before taxes'

    def test_parse_discounted_price(self):
        """Test that discounted prices are preferred over regular prices."""
        result = parse_listings(MOCK_API_RESPONSE)
        listing = result['listings'][1]
        
        assert listing['total_price'] == '$380 total'
        assert listing['original_price'] == '$420 total'
        assert listing['total_price_num'] == 380

    def test_parse_city_fallback(self):
        """Test that city falls back to geography when listing.city is None."""
        result = parse_listings(MOCK_API_RESPONSE)
        listing = result['listings'][1]
        
        assert listing['city'] == 'Steamboat Springs'

    def test_parse_empty_response(self):
        """Test handling of empty response."""
        result = parse_listings({})
        
        assert result['listings'] == []
        assert result['total_count'] is None
        assert result['has_next_page'] is None

    def test_ignores_non_listing_sections(self):
        """Test that non-listing sections are ignored."""
        result = parse_listings(MOCK_API_RESPONSE)
        
        # Should only have 2 listings, not items from MapMarkerSection
        assert len(result['listings']) == 2


@pytest.mark.integration
class TestSearchAirbnb:
    """Integration tests for search_airbnb function.
    
    These tests make real API calls and may fail if the Airbnb API changes.
    Run with: pytest -m integration
    """

    def test_search_returns_dict(self):
        """Test that search returns a dictionary."""
        result = search_airbnb('New York, NY', items_per_page=5)
        
        assert isinstance(result, dict)
        assert 'data' in result

    def test_search_with_dates(self):
        """Test search with check-in/check-out dates."""
        result = search_airbnb(
            'Denver, CO',
            checkin='2026-03-15',
            checkout='2026-03-17',
            items_per_page=5
        )
        
        assert isinstance(result, dict)
        assert 'data' in result

    def test_search_with_filters(self):
        """Test search with price and bedroom filters."""
        result = search_airbnb(
            'Austin, TX',
            min_price=100,
            max_price=500,
            min_bedrooms=2,
            items_per_page=5
        )
        
        assert isinstance(result, dict)


@pytest.mark.integration
class TestEndToEnd:
    """End-to-end integration tests."""

    def test_full_search_flow(self):
        """Test complete search and parse flow."""
        raw = search_airbnb('Seattle, WA', items_per_page=10)
        result = parse_listings(raw)
        
        assert 'listings' in result
        assert isinstance(result['listings'], list)
        # Should get some results for Seattle
        assert len(result['listings']) > 0

    def test_listing_has_required_fields(self):
        """Test that parsed listings have all required fields."""
        raw = search_airbnb('Portland, OR', items_per_page=5)
        result = parse_listings(raw)
        
        if result['listings']:  # Only test if we got results
            listing = result['listings'][0]
            required_fields = ['id', 'name', 'url', 'bedrooms', 'total_price']
            for field in required_fields:
                assert field in listing, f"Missing field: {field}"


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