文件内容
searchch.py
#!/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
"""
api_key = get_api_key()
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()