Last active
January 8, 2026 09:27
-
-
Save qianlifeng/8caa20ae71e0c2ec2c3c787485890668 to your computer and use it in GitHub Desktop.
Wox.Plugin.Script.IPGeolocation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # { | |
| # "Id": "8b8a1b35-3d9e-4d7d-9f2e-3b1d0b7f9e10", | |
| # "Name": "IP Geolocation", | |
| # "Author": "qianlifeng", | |
| # "Version": "1.0.1", | |
| # "MinWoxVersion": "2.0.0", | |
| # "Description": "Show local/public IPs and query geolocation via ip-api.com", | |
| # "Icon": "emoji:📍", | |
| # "TriggerKeywords": ["ip"], | |
| # "SupportedOS": ["windows", "linux", "darwin"], | |
| # "I18n": { | |
| # "en_US": { | |
| # "plugin.description": "Show local/public IPs and query geolocation via ip-api.com", | |
| # "query.prompt": "Tips: Enter IP or domain to query", | |
| # "query.example": "e.g.: ip 8.8.8.8 or ip example.com", | |
| # "local.ip": "Local IPv4", | |
| # "public.ip": "Public IP", | |
| # "public.ip.notfound": "Public IP not found", | |
| # "public.ip.notfound.subtitle": "Network may be offline or endpoints blocked", | |
| # "geo.header": "Query", | |
| # "geo.type": "Type", | |
| # "geo.location": "Location", | |
| # "geo.coords": "Coordinates(lat, lon)", | |
| # "geo.tz.zip": "Timezone / ZIP", | |
| # "geo.isp": "ISP", | |
| # "action.copy": "Copy", | |
| # "action.copy.ip": "Copy IP", | |
| # "action.copy.location": "Copy Location", | |
| # "action.copy.coords": "Copy", | |
| # "action.open.api": "Open API", | |
| # "action.open.maps": "Open Maps", | |
| # "error.query.failed": "Query failed" | |
| # }, | |
| # "zh_CN": { | |
| # "plugin.description": "显示本地/公网 IP,并通过 ip-api.com 查询地理位置", | |
| # "query.prompt": "提示: 输入 IP 或域名 进行查询", | |
| # "query.example": "例如: ip 8.8.8.8 或 ip example.com", | |
| # "local.ip": "本地 IPv4", | |
| # "public.ip": "公网 IP", | |
| # "public.ip.notfound": "未获取到公网 IP", | |
| # "public.ip.notfound.subtitle": "网络离线或端点被阻止", | |
| # "geo.header": "查询", | |
| # "geo.type": "类型", | |
| # "geo.location": "位置", | |
| # "geo.coords": "坐标(经度, 纬度)", | |
| # "geo.tz.zip": "时区 / 邮编", | |
| # "geo.isp": "运营商", | |
| # "action.copy": "复制", | |
| # "action.copy.ip": "复制 IP", | |
| # "action.copy.location": "复制位置", | |
| # "action.copy.coords": "复制", | |
| # "action.open.api": "打开 API", | |
| # "action.open.maps": "打开地图", | |
| # "error.query.failed": "查询失败" | |
| # } | |
| # } | |
| # } | |
| from __future__ import annotations | |
| import datetime | |
| import json | |
| import os | |
| import socket | |
| import sys | |
| import urllib.parse | |
| import urllib.request | |
| import urllib.error | |
| from typing import Any, Dict, List, TypedDict | |
| # Simple i18n store; can be extended or read from header if needed | |
| I18N: Dict[str, Dict[str, str]] = { | |
| "en_US": { | |
| "plugin.description": "Show local/public IPs and query geolocation via ip-api.com", | |
| "query.prompt": "Enter IP or domain to query", | |
| "query.example": "e.g.: ip 8.8.8.8 or ip example.com", | |
| "local.ip": "Local IPv4", | |
| "public.ip": "Public IP", | |
| "public.ip.notfound": "Public IP not found", | |
| "public.ip.notfound.subtitle": "Network may be offline or endpoints blocked", | |
| "geo.header": "Query", | |
| "geo.type": "Type", | |
| "geo.location": "Location", | |
| "geo.coords": "Coordinates", | |
| "geo.tz.zip": "Timezone / ZIP", | |
| "geo.isp": "ISP", | |
| "action.copy": "Copy", | |
| "action.copy.ip": "Copy IP", | |
| "action.copy.location": "Copy Location", | |
| "action.copy.coords": "Copy", | |
| "action.open.api": "Open API", | |
| "action.open.maps": "Open Maps", | |
| "error.query.failed": "Query failed", | |
| }, | |
| "zh_CN": { | |
| "plugin.description": "显示本地/公网 IP,并通过 ip-api.com 查询地理位置", | |
| "query.prompt": "输入 IP 或域名 进行查询", | |
| "query.example": "例如: ip 8.8.8.8 或 ip example.com", | |
| "local.ip": "本地 IPv4", | |
| "public.ip": "公网 IP", | |
| "public.ip.notfound": "未获取到公网 IP", | |
| "public.ip.notfound.subtitle": "网络离线或端点被阻止", | |
| "geo.header": "查询", | |
| "geo.type": "类型", | |
| "geo.location": "位置", | |
| "geo.coords": "坐标", | |
| "geo.tz.zip": "时区 / 邮编", | |
| "geo.isp": "运营商", | |
| "action.copy": "复制", | |
| "action.copy.ip": "复制 IP", | |
| "action.copy.location": "复制位置", | |
| "action.copy.coords": "复制", | |
| "action.open.api": "打开 API", | |
| "action.open.maps": "打开地图", | |
| "error.query.failed": "查询失败", | |
| }, | |
| } | |
| class WoxPluginBase: | |
| """Wox plugin base class for script plugins. Do not modify this class.""" | |
| class Preview(TypedDict, total=False): | |
| """Type hint for preview content in a result.""" | |
| preview_type: str # "markdown", "text" | |
| preview_data: str | |
| preview_properties: dict[str, str] | |
| class ActionItem(TypedDict, total=False): | |
| """Type hint for an action in a result.""" | |
| id: str | |
| name: str | |
| icon: str | |
| """ | |
| support following icon formats: | |
| - "base64:data:image/png;base64,xxx" | |
| - "emoji:😀" | |
| - "svg:<svg>...</svg>" | |
| - "fileicon:/absolute/path/to/file" (get system file icon) | |
| - "absolute:/absolute/path/to/image.png" | |
| - "url:https://example.com/image.png" | |
| """ | |
| text: str | |
| url: str | |
| path: str | |
| message: str | |
| data: dict[str, str] | |
| class TailItem(TypedDict, total=False): | |
| """Type hint for a tail in a result.""" | |
| id: str | |
| type: str # "text" or "image" | |
| text: str | |
| image: str | |
| contextData: dict[str, str] | |
| class QueryResult(TypedDict, total=False): | |
| """Type hint for a query result item.""" | |
| title: str | |
| subtitle: str | |
| icon: str | |
| """ | |
| support following icon formats: | |
| - "base64:data:image/png;base64,xxx" | |
| - "emoji:😀" | |
| - "svg:<svg>...</svg>" | |
| - "fileicon:/absolute/path/to/file" (get system file icon) | |
| - "absolute:/absolute/path/to/image.png" | |
| - "url:https://example.com/image.png" | |
| """ | |
| preview: WoxPluginBase.Preview | |
| tails: List[WoxPluginBase.TailItem] | |
| score: int | |
| actions: List[WoxPluginBase.ActionItem] | |
| class ActionResult(TypedDict, total=False): | |
| """Type hint for action method return value.""" | |
| action: str | |
| message: str | |
| def __init__(self): | |
| self.log_file_path = __file__ + ".log" | |
| def log(self, message: str) -> None: | |
| """Log message to log file for debugging.""" | |
| ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| message = f"[{ts}] {message}" | |
| if self.is_invoke_from_wox(): | |
| with open(self.log_file_path, "a", encoding="utf-8") as f: | |
| f.write(f"{message}\n") | |
| else: | |
| print(f"LOG: {message}") | |
| def is_invoke_from_wox(self) -> bool: | |
| """Check if the script is invoked from Wox.""" | |
| return "WOX_PLUGIN_ID" in os.environ | |
| def _build_response(self, result: Any, request_id: Any) -> dict: | |
| """Build a successful JSON-RPC 2.0 response.""" | |
| return {"jsonrpc": "2.0", "result": result, "id": request_id} | |
| def _build_error_response( | |
| self, code: int, message: str, data: Any = None, request_id: Any = None | |
| ) -> dict: | |
| """Build a JSON-RPC 2.0 error response.""" | |
| error = {"code": code, "message": message} | |
| if data is not None: | |
| error["data"] = data | |
| return {"jsonrpc": "2.0", "error": error, "id": request_id} | |
| def query( | |
| self, raw_query: str, trigger_keyword: str, command: str, search: str | |
| ) -> List[WoxPluginBase.QueryResult]: | |
| """ | |
| Process user queries and return results. | |
| Args: | |
| raw_query: The full raw query string entered by the user | |
| trigger_keyword: The trigger keyword that activated this plugin | |
| command: The command part of the query (after trigger keyword) | |
| search: The search part of the query (after command) | |
| take `wpm install plugin-name` as an example | |
| raw_query: wpm install plugin-name | |
| trigger_keyword: wpm | |
| command: install | |
| search: plugin-name | |
| Returns: | |
| List of result dictionaries | |
| """ | |
| # Default implementation - override in your plugin class | |
| return [ | |
| { | |
| "title": "Hello Wox!", | |
| "subtitle": "This is a default result. Override the query method in your plugin class.", | |
| "score": 100, | |
| "actions": [ | |
| {"name": "Copy", "id": "copy-to-clipboard", "text": "Hello Wox!"} | |
| ], | |
| } | |
| ] | |
| def action(self, action_id: str, data: Any) -> None: | |
| """ | |
| Handle action requests (OPTIONAL - only needed for custom actions) | |
| Built-in actions (copy-to-clipboard, open-url, open-directory, notify) are handled | |
| automatically by Wox. You only need to implement this method if you have custom actions. | |
| Note: This method is called as a hook for ALL actions (built-in and custom), so you can | |
| optionally add additional logic for built-in actions if needed. | |
| Args: | |
| action_id: The ID of the action | |
| data: Additional data for the action | |
| """ | |
| # Default implementation - override for custom actions | |
| if action_id == "custom-action": | |
| # Custom action handling logic can be added here | |
| pass | |
| def handle_query(self, params, request_id): | |
| """Internal method to handle query requests""" | |
| search = params.get("search", "") | |
| trigger_keyword = params.get("trigger_keyword", "") | |
| command = params.get("command", "") | |
| raw_query = params.get("raw_query", "") | |
| results = self.query(raw_query, trigger_keyword, command, search) | |
| return self._build_response({"items": results}, request_id) | |
| def handle_action(self, params, request_id): | |
| """Internal method to handle action requests""" | |
| action_id = params.get("id", "") | |
| action_data = params.get("data", "") | |
| self.action(action_id, action_data) | |
| return self._build_response({}, request_id) | |
| def run(self): | |
| """Main entry point for the script plugin""" | |
| # Parse input | |
| if self.is_invoke_from_wox(): | |
| # Running from Wox - read from stdin | |
| try: | |
| stdin_text = sys.stdin.read() | |
| except Exception: | |
| return 1 | |
| else: | |
| # Manual testing mode | |
| print("Manual mode - please enter query:") | |
| query_input = input() | |
| stdin_text = f'{{"jsonrpc": "2.0", "method": "query", "params": {{"query": "{query_input}"}}, "id": 1}}' | |
| # Parse JSON-RPC 2.0 request | |
| try: | |
| request = json.loads(stdin_text) | |
| except json.JSONDecodeError as e: | |
| error_response = self._build_error_response( | |
| -32700, "Parse error", str(e), None | |
| ) | |
| print(json.dumps(error_response, ensure_ascii=False)) | |
| return 1 | |
| # Validate JSON-RPC 2.0 format | |
| if request.get("jsonrpc") != "2.0": | |
| error_response = self._build_error_response( | |
| -32600, "Invalid Request", None, request.get("id") | |
| ) | |
| print(json.dumps(error_response, ensure_ascii=False)) | |
| return 1 | |
| method = request.get("method") | |
| params = request.get("params", {}) | |
| request_id = request.get("id") | |
| # Handle different methods | |
| if method == "query": | |
| response = self.handle_query(params, request_id) | |
| elif method == "action": | |
| response = self.handle_action(params, request_id) | |
| else: | |
| # Method not found | |
| response = self._build_error_response( | |
| -32601, | |
| "Method not found", | |
| f"Method '{method}' not supported", | |
| request_id, | |
| ) | |
| # Output response | |
| print(json.dumps(response, ensure_ascii=False)) | |
| return 0 | |
| class IPGeolocationPlugin(WoxPluginBase): | |
| ICON = "emoji:📍" | |
| TIP_ICON = "emoji:💡" | |
| API_BASE = "http://ip-api.com/json/" | |
| def _get_public_ip(self) -> str: | |
| # Use multiple fallback endpoints | |
| endpoints = [ | |
| "https://api.ipify.org", | |
| "https://ipv4.icanhazip.com", | |
| "https://ifconfig.me/ip", | |
| ] | |
| for url in endpoints: | |
| try: | |
| with urllib.request.urlopen(url, timeout=4) as resp: | |
| text = resp.read().decode("utf-8", errors="ignore").strip() | |
| if text: | |
| return text | |
| except Exception as e: | |
| self.log(f"public ip endpoint failed {url}: {e}") | |
| return "" | |
| def _get_local_ips(self) -> List[str]: | |
| ips: List[str] = [] | |
| try: | |
| hostname = socket.gethostname() | |
| # primary | |
| try: | |
| primary = socket.gethostbyname(hostname) | |
| if primary and primary not in ips and not primary.startswith("127."): | |
| ips.append(primary) | |
| except Exception: | |
| pass | |
| # enumerate via interfaces (basic) | |
| try: | |
| for addrinfo in socket.getaddrinfo(hostname, None): | |
| ip = addrinfo[4][0] | |
| if ( | |
| ip | |
| and isinstance(ip, str) | |
| and ip not in ips | |
| and not ip.startswith("127.") | |
| and ":" not in ip | |
| ): | |
| ips.append(ip) | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| self.log(f"local ip error: {e}") | |
| return ips | |
| def _geo(self, ip_or_domain: str) -> Dict[str, Any]: | |
| target = ip_or_domain.strip() | |
| if not target: | |
| return {"status": "fail", "message": "empty target"} | |
| url = self.API_BASE + urllib.parse.quote(target) | |
| try: | |
| with urllib.request.urlopen(url, timeout=6) as resp: | |
| txt = resp.read().decode("utf-8", errors="ignore") | |
| obj = json.loads(txt) | |
| if isinstance(obj, dict): | |
| return obj | |
| return {"status": "fail", "message": "invalid json"} | |
| except urllib.error.HTTPError as e: | |
| return {"status": "fail", "message": f"http {e.code}"} | |
| except urllib.error.URLError as e: | |
| return {"status": "fail", "message": f"network {e.reason}"} | |
| except Exception as e: | |
| return {"status": "fail", "message": str(e)} | |
| def _format_geo_title(self, data: Dict[str, Any]) -> str: | |
| # Fallback title when needed | |
| return "Geolocation Result" | |
| def _build_geo_items( | |
| self, query: str, data: Dict[str, Any], header_title: str = "i18n:geo.header" | |
| ) -> List[WoxPluginBase.QueryResult]: | |
| status = data.get("status") | |
| if status != "success": | |
| msg = data.get("message") or "Query failed" | |
| return [ | |
| { | |
| "title": f"{query}", | |
| "subtitle": str(msg), | |
| "icon": self.ICON, | |
| "actions": [ | |
| { | |
| "name": "Open API", | |
| "id": "open-url", | |
| "url": self.API_BASE + urllib.parse.quote(query), | |
| }, | |
| { | |
| "name": "Copy Message", | |
| "id": "copy-to-clipboard", | |
| "text": str(msg), | |
| }, | |
| ], | |
| } | |
| ] | |
| ip = data.get("query") or query | |
| items: List[WoxPluginBase.QueryResult] = [] | |
| # Header item: the IP queried | |
| items.append( | |
| { | |
| "title": header_title, | |
| "subtitle": f"{ip} | {data.get('as', '')}", | |
| "icon": self.ICON, | |
| "actions": [ | |
| { | |
| "name": "i18n:action.copy.ip", | |
| "id": "copy-to-clipboard", | |
| "text": str(ip), | |
| }, | |
| { | |
| "name": "i18n:action.open.api", | |
| "id": "open-url", | |
| "url": self.API_BASE + urllib.parse.quote(ip), | |
| }, | |
| ], | |
| } | |
| ) | |
| # Location details | |
| country = data.get("country") | |
| region = data.get("regionName") | |
| city = data.get("city") | |
| zip_code = data.get("zip") | |
| timezone = data.get("timezone") | |
| lat = data.get("lat") | |
| lon = data.get("lon") | |
| isp = data.get("isp") | |
| org = data.get("org") | |
| if country or region or city: | |
| items.append( | |
| { | |
| "title": "i18n:geo.location", | |
| "subtitle": " ".join( | |
| [str(v) for v in [country, region, city] if v] | |
| ), | |
| "icon": self.ICON, | |
| "actions": [ | |
| { | |
| "name": "i18n:action.copy.location", | |
| "id": "copy-to-clipboard", | |
| "text": " ".join( | |
| [str(v) for v in [country, region, city] if v] | |
| ), | |
| }, | |
| { | |
| "name": "i18n:action.open.maps", | |
| "id": "open-url", | |
| "url": f"https://www.google.com/maps/search/?api=1&query={lat},{lon}", | |
| } | |
| if lat is not None and lon is not None | |
| else { | |
| "name": "i18n:action.copy.coords", | |
| "id": "copy-to-clipboard", | |
| "text": f"{lat},{lon}", | |
| }, | |
| ], | |
| } | |
| ) | |
| if zip_code or timezone: | |
| items.append( | |
| { | |
| "title": "i18n:geo.tz.zip", | |
| "subtitle": " | ".join( | |
| [ | |
| s | |
| for s in [ | |
| str(zip_code) if zip_code else None, | |
| str(timezone) if timezone else None, | |
| ] | |
| if s | |
| ] | |
| ), | |
| "icon": self.ICON, | |
| } | |
| ) | |
| if isp or org: | |
| items.append( | |
| { | |
| "title": "i18n:geo.isp", | |
| "subtitle": " | ".join( | |
| [ | |
| s | |
| for s in [ | |
| str(isp) if isp else None, | |
| str(org) if org else None, | |
| ] | |
| if s | |
| ] | |
| ), | |
| "icon": self.ICON, | |
| } | |
| ) | |
| # Coordinates item explicitly | |
| if lat is not None and lon is not None: | |
| items.append( | |
| { | |
| "title": "i18n:geo.coords", | |
| "subtitle": f"{lat}, {lon}", | |
| "icon": self.ICON, | |
| "actions": [ | |
| { | |
| "name": "i18n:action.open.maps", | |
| "id": "open-url", | |
| "url": f"https://www.google.com/maps/search/?api=1&query={lat},{lon}", | |
| }, | |
| { | |
| "name": "i18n:action.copy.coords", | |
| "id": "copy-to-clipboard", | |
| "text": f"{lat},{lon}", | |
| }, | |
| ], | |
| } | |
| ) | |
| return items | |
| def query( | |
| self, raw_query: str, trigger_keyword: str, command: str, search: str | |
| ) -> List[WoxPluginBase.QueryResult]: | |
| items: List[WoxPluginBase.QueryResult] = [] | |
| # If user provides a query, perform geolocation for that IP/domain | |
| if search: | |
| data = self._geo(search) | |
| items.extend(self._build_geo_items(search, data)) | |
| return items | |
| # No query: show local and public IPs, with actions to geolocate | |
| local_ips = self._get_local_ips() | |
| public_ip = self._get_public_ip() | |
| for lip in local_ips: | |
| items.append( | |
| { | |
| "title": "i18n:local.ip", | |
| "subtitle": lip, | |
| "icon": self.ICON, | |
| "actions": [ | |
| { | |
| "name": "i18n:action.copy", | |
| "id": "copy-to-clipboard", | |
| "text": lip, | |
| }, | |
| { | |
| "name": "i18n:action.open.api", | |
| "id": "open-url", | |
| "url": self.API_BASE + urllib.parse.quote(lip), | |
| }, | |
| ], | |
| } | |
| ) | |
| if public_ip: | |
| location_detail= "" | |
| public_geo = self._geo(public_ip) | |
| if public_geo.get("status") == "success": | |
| country = public_geo.get("country") | |
| region = public_geo.get("regionName") | |
| city = public_geo.get("city") | |
| isp = public_geo.get("isp") or public_geo.get("org") | |
| location = " ".join([str(v) for v in [country, region, city] if v]) | |
| tail_parts = [p for p in [location, str(isp) if isp else None] if p] | |
| location_detail = " | ".join(tail_parts) | |
| items.append( | |
| { | |
| "title": "i18n:public.ip", | |
| "subtitle": public_ip, | |
| "tails": [ | |
| { | |
| "type": "text", | |
| "text": location_detail, | |
| } | |
| ], | |
| "icon": self.ICON, | |
| "actions": [ | |
| { | |
| "name": "i18n:action.copy", | |
| "id": "copy-to-clipboard", | |
| "text": public_ip, | |
| }, | |
| { | |
| "name": "i18n:action.open.api", | |
| "id": "open-url", | |
| "url": self.API_BASE + urllib.parse.quote(public_ip), | |
| }, | |
| ], | |
| } | |
| ) | |
| else: | |
| items.append( | |
| { | |
| "title": "i18n:public.ip.notfound", | |
| "subtitle": "i18n:public.ip.notfound.subtitle", | |
| "icon": self.ICON, | |
| } | |
| ) | |
| return items | |
| if __name__ == "__main__": | |
| sys.exit(IPGeolocationPlugin().run()) |
Author
qianlifeng
commented
Dec 13, 2025
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
