Skill: xenofex7/swiss-phone-directory (version 1.1.2)

--- name: swiss-phone-directory description: "Swiss phone directory lookup via search.ch API. Search for businesses, people, or reverse-lookup phone numbers. Use when: (1) finding contact details for Swiss companies or people, (2) looking up addresses by name or phone number, (3) reverse phone number lookup, (4) finding business categories. Requires SEARCHCH_API_KEY." metadata: openclaw: requires: env: - SEARCHCH_API_KEY --- # Swiss Phone Directory Skill Search the Swiss phone directory (search.ch) for businesses, people, and phone numbers. ## Quick Start ```bash # Search for a business python3 scripts/searchch.py search "Migros" --location "Zürich" # Search for a person python3 scripts/searchch.py search "Müller Hans" --type person # Reverse phone number lookup python3 scripts/searchch.py search "+41442345678" # Business-only search python3 scripts/searchch.py search "Restaurant" --location "Bern" --type business --limit 5 ``` ## Commands ### search Search for businesses, people, or phone numbers. ```bash python3 scripts/searchch.py search <query> [options] Options: --location, -l City, ZIP, street, or canton (e.g., "Zürich", "8000", "ZH") --type, -t Filter: "business", "person", or "all" (default: all) --limit, -n Max results (default: 10, max: 200) --lang Output language: de, fr, it, en (default: de) ``` ### Examples ```bash # Find restaurants in Rapperswil python3 scripts/searchch.py search "Restaurant" -l "Rupperswil" -t business -n 5 # Find a person by name python3 scripts/searchch.py search "Meier Peter" -l "Zürich" -t person # Reverse lookup a phone number python3 scripts/searchch.py search "044 123 45 67" # Search with canton abbreviation python3 scripts/searchch.py search "Bäckerei" -l "SG" ``` ## Output Format Results include (when available): - **Name** - Business or person name - **Type** - Organisation or Person - **Address** - Street, ZIP, city, canton - **Phone** - Clickable tel: link (e.g., `[044 123 45 67](tel:+41441234567)`) - **Fax** - Clickable tel: link - **Email** - Email address - **Website** - Website URL - **Categories** - Business categories ### Clickable Phone Numbers 📞 Phone numbers are automatically formatted as Markdown links with `tel:` protocol: ``` 📞 [044 123 45 67](tel:+41441234567) ``` This enables **one-tap calling** on mobile devices (Telegram, Signal, WhatsApp, etc.). To disable clickable links, use `--no-clickable`. ## Configuration ### Get an API Key (free) 1. **Request a key:** https://search.ch/tel/api/getkey.en.html 2. Fill out the form (name, email, use case) 3. **Approval:** ~10-15 minutes, key arrives via email ### Set the Environment Variable ```bash export SEARCHCH_API_KEY="your-api-key-here" ``` For permanent setup, see [references/configuration.md](references/configuration.md). ## API Reference - Base URL: `https://search.ch/tel/api/` - Rate limits: Depend on API key tier - Full docs: https://search.ch/tel/api/help.en.html

path: configuration.md
content: # Configuration

## API Key Setup

The skill requires a search.ch API key stored in the `SEARCHCH_API_KEY` environment variable.

### Get an API Key

1. Visit https://search.ch/tel/api/getkey.en.html
2. Fill out the request form
3. Receive your API key via email

### Set the Environment Variable

**Temporary (current session):**
```bash
export SEARCHCH_API_KEY="your-api-key-here"
```

**Permanent (add to shell profile):**
```bash
# For bash (~/.bashrc or ~/.bash_profile)
echo 'export SEARCHCH_API_KEY="your-api-key-here"' >> ~/.bashrc
source ~/.bashrc

# For zsh (~/.zshrc)
echo 'export SEARCHCH_API_KEY="your-api-key-here"' >> ~/.zshrc
source ~/.zshrc
```

**For Clawdbot Gateway:**

Add to your gateway config or environment:
```yaml
env:
  SEARCHCH_API_KEY: "your-api-key-here"
```

## API Limits

- Without API key: Limited queries, no structured data
- With API key: More queries per day, full structured data (Atom feed)
- Maximum results per query: 200

## Troubleshooting

### "Invalid API key" error
- Verify the key is correctly set: `echo $SEARCHCH_API_KEY`
- Check for extra whitespace or quotes in the key
- Ensure the key hasn't expired

### "Network Error"
- Check internet connectivity
- Verify https://search.ch is accessible
- Check for firewall/proxy issues

### No results found
- Try broader search terms
- Remove location filter to search nationwide
- Check spelling (umlauts matter: ü, ö, ä)
- Try different name variations

## Terms of Use

By using the search.ch API, you agree to their terms:
https://search.ch/tel/api/terms.en.html

Key points:
- Attribution required for public-facing applications
- Rate limits apply
- Commercial use may require special agreement


path: searchch.py
content: #!/usr/bin/env python3
"""
Swiss Phone Directory CLI - search.ch API wrapper
Search for businesses, people, or reverse-lookup phone numbers in Switzerland.
"""

import argparse
import os
import sys
import urllib.request
import urllib.parse
import xml.etree.ElementTree as ET
from typing import Optional


API_BASE = "https://search.ch/tel/api/"
NS = {
    "atom": "http://www.w3.org/2005/Atom",
    "tel": "http://tel.search.ch/api/spec/result/1.0/",
    "openSearch": "http://a9.com/-/spec/opensearchrss/1.0/"
}


def get_api_key() -> str:
    """Get API key from environment variable."""
    key = os.environ.get("SEARCHCH_API_KEY")
    if not key:
        print("❌ Error: SEARCHCH_API_KEY environment variable not set", file=sys.stderr)
        print("   Set it with: export SEARCHCH_API_KEY='your-api-key'", file=sys.stderr)
        sys.exit(1)
    return key


def search(query: str, location: Optional[str] = None, entry_type: str = "all",
           limit: int = 10, lang: str = "de") -> list[dict]:
    """
    Search the Swiss phone directory.
    
    Args:
        query: Search string (name, category, or phone number)
        location: City, ZIP, street, or canton abbreviation
        entry_type: "business", "person", or "all"
        limit: Maximum results (max 200)
        lang: Output language (de, fr, it, en)
    
    Returns:
        List of result dictionaries
    """
    [REDACTED_SECRET])
    
    params = {
        "was": query,
        "key": api_key,
        "lang": lang,
        "maxnum": min(limit, 200)
    }
    
    if location:
        params["wo"] = location
    
    if entry_type == "business":
        params["privat"] = "0"
        params["firma"] = "1"
    elif entry_type == "person":
        params["privat"] = "1"
        params["firma"] = "0"
    
    url = API_BASE + "?" + urllib.parse.urlencode(params)
    
    try:
        with urllib.request.urlopen(url, timeout=15) as response:
            xml_data = response.read().decode("utf-8")
    except urllib.error.HTTPError as e:
        if e.code == 403:
            print("❌ Error: Invalid API key", file=sys.stderr)
        else:
            print(f"❌ HTTP Error: {e.code}", file=sys.stderr)
        sys.exit(1)
    except urllib.error.URLError as e:
        print(f"❌ Network Error: {e.reason}", file=sys.stderr)
        sys.exit(1)
    
    return parse_response(xml_data)


def parse_response(xml_data: str) -> list[dict]:
    """Parse the Atom XML response into a list of dictionaries."""
    root = ET.fromstring(xml_data)
    
    # Get total results
    total_el = root.find("openSearch:totalResults", NS)
    total = int(total_el.text) if total_el is not None else 0
    
    results = []
    
    for entry in root.findall("atom:entry", NS):
        result = {}
        
        # Basic info
        result["name"] = get_text(entry, "atom:title")
        result["id"] = get_text(entry, "tel:id")
        result["type"] = get_text(entry, "tel:type")
        
        # Organization details
        org = get_text(entry, "tel:org")
        if org:
            result["org"] = org
        
        firstname = get_text(entry, "tel:firstname")
        lastname = get_text(entry, "tel:name")
        if firstname:
            result["firstname"] = firstname
        if lastname and lastname != result.get("name"):
            result["lastname"] = lastname
        
        occupation = get_text(entry, "tel:occupation")
        if occupation:
            result["occupation"] = occupation
        
        # Address
        street = get_text(entry, "tel:street")
        streetno = get_text(entry, "tel:streetno")
        zip_code = get_text(entry, "tel:zip")
        city = get_text(entry, "tel:city")
        canton = get_text(entry, "tel:canton")
        
        if street:
            result["street"] = f"{street} {streetno}".strip() if streetno else street
        if zip_code:
            result["zip"] = zip_code
        if city:
            result["city"] = city
        if canton:
            result["canton"] = canton
        
        # Phone
        phone = get_text(entry, "tel:phone")
        if phone:
            result["phone"] = format_phone(phone)
        
        # Extra fields (fax, email, website)
        for extra in entry.findall("tel:extra", NS):
            extra_type = extra.get("type", "")
            value = extra.text
            if value:
                value = value.rstrip("*")  # Remove no-promo marker
                if extra_type == "fax":
                    result["fax"] = format_phone(value)
                elif extra_type == "email":
                    result["email"] = value
                elif extra_type == "website":
                    # Parse "label: url" format
                    if ": http" in value:
                        result["website"] = value.split(": ", 1)[1]
                    elif value.startswith("http"):
                        result["website"] = value
                    elif "website" not in result:
                        result["website"] = value
        
        # Categories
        categories = []
        for cat in entry.findall("tel:category", NS):
            if cat.text:
                categories.append(cat.text)
        if categories:
            result["categories"] = categories
        
        # Detail link
        for link in entry.findall("atom:link", NS):
            if link.get("rel") == "alternate" and link.get("type") == "text/html":
                result["url"] = link.get("href")
                break
        
        results.append(result)
    
    return results


def get_text(element: ET.Element, path: str) -> Optional[str]:
    """Get text content of a child element."""
    child = element.find(path, NS)
    return child.text.strip() if child is not None and child.text else None


def format_phone(phone: str, clickable: bool = True) -> str:
    """Format phone number for display with optional clickable tel: link."""
    # Remove non-digits except +
    digits = "".join(c for c in phone if c.isdigit() or c == "+")
    
    # Normalize to international format for tel: link
    if digits.startswith("0") and len(digits) == 10:
        tel_digits = "+41" + digits[1:]
    elif digits.startswith("+"):
        tel_digits = digits
    else:
        tel_digits = "+41" + digits
    
    # Format display number
    if digits.startswith("+41") and len(digits) == 12:
        display = f"+41 {digits[3:5]} {digits[5:8]} {digits[8:10]} {digits[10:12]}"
    elif digits.startswith("0") and len(digits) == 10:
        display = f"{digits[0:3]} {digits[3:6]} {digits[6:8]} {digits[8:10]}"
    else:
        display = phone
    
    # Return clickable markdown link or plain number
    if clickable:
        return f"[{display}](tel:{tel_digits})"
    return display


def print_results(results: list[dict], verbose: bool = False, clickable: bool = True):
    """Print search results in a readable format with clickable phone links."""
    if not results:
        print("🔍 Keine Treffer gefunden.")
        return
    
    print(f"📋 {len(results)} Treffer:\n")
    
    for i, r in enumerate(results, 1):
        # Name and type
        type_icon = "🏢" if r.get("type") == "Organisation" else "👤"
        print(f"{type_icon} **{r.get('name', 'Unbekannt')}**")
        
        # Occupation/subtitle
        if r.get("occupation"):
            print(f"   {r['occupation']}")
        
        # Address
        addr_parts = []
        if r.get("street"):
            addr_parts.append(r["street"])
        if r.get("zip") or r.get("city"):
            addr_parts.append(f"{r.get('zip', '')} {r.get('city', '')}".strip())
        if r.get("canton"):
            addr_parts[-1] = f"{addr_parts[-1]} {r['canton']}" if addr_parts else r["canton"]
        if addr_parts:
            print(f"   📍 {', '.join(addr_parts)}")
        
        # Contact - phone numbers with clickable tel: links
        if r.get("phone"):
            phone_display = format_phone(r['phone'], clickable=clickable) if clickable else r['phone']
            print(f"   📞 {phone_display}")
        if r.get("fax"):
            fax_display = format_phone(r['fax'], clickable=clickable) if clickable else r['fax']
            print(f"   📠 {fax_display}")
        if r.get("email"):
            print(f"   ✉️  {r['email']}")
        if r.get("website"):
            print(f"   🔗 {r['website']}")
        
        # Categories
        if r.get("categories") and verbose:
            print(f"   🏷️  {', '.join(r['categories'])}")
        
        print()


def main():
    parser = argparse.ArgumentParser(
        description="Search the Swiss phone directory (search.ch)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  %(prog)s search "Migros" -l "Zürich"
  %(prog)s search "Müller Hans" -t person
  %(prog)s search "+41442345678"
  %(prog)s search "Restaurant" -l "Bern" -t business -n 5
        """
    )
    
    subparsers = parser.add_subparsers(dest="command", required=True)
    
    # Search command
    search_parser = subparsers.add_parser("search", help="Search the directory")
    search_parser.add_argument("query", help="Search string (name, category, or phone number)")
    search_parser.add_argument("-l", "--location", help="City, ZIP, street, or canton")
    search_parser.add_argument("-t", "--type", choices=["business", "person", "all"],
                               default="all", help="Entry type filter (default: all)")
    search_parser.add_argument("-n", "--limit", type=int, default=10,
                               help="Max results (default: 10, max: 200)")
    search_parser.add_argument("--lang", choices=["de", "fr", "it", "en"],
                               default="de", help="Output language (default: de)")
    search_parser.add_argument("-v", "--verbose", action="store_true",
                               help="Show categories and extra details")
    search_parser.add_argument("--no-clickable", action="store_true",
                               help="Disable clickable tel: links in phone numbers")
    search_parser.add_argument("--json", action="store_true",
                               help="Output raw JSON")
    
    args = parser.parse_args()
    
    if args.command == "search":
        results = search(
            query=args.query,
            location=args.location,
            entry_type=args.type,
            limit=args.limit,
            lang=args.lang
        )
        
        if args.json:
            import json
            print(json.dumps(results, ensure_ascii=False, indent=2))
        else:
            print_results(results, verbose=args.verbose, clickable=not args.no_clickable)


if __name__ == "__main__":
    main()


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