Initial commit: Unified API Gateway v3.0.0

This commit is contained in:
dvs
2026-08-28 10:06:18 +08:00
commit 7d9e221993
5 changed files with 3388 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.venv/
venv/
env/
# Environment / secrets
.env
*.key
*.pem
# System files
Thumbs.db
.DS_Store
desktop.ini
+578
View File
@@ -0,0 +1,578 @@
"""
Unified API Gateway v3.0
Wraps all external APIs in /api/v3/ format
Supports precise city name matching (adcode.txt)
Server deployment version
"""
import json
import re
import requests
from flask import Flask, request, jsonify
from datetime import datetime
app = Flask(__name__)
# ========== Configuration ==========
# Time API - using api.zxki.cn (simple and reliable)
TIME_API_URL = "https://api.zxki.cn/api/time"
# Weather API - sojson (uses citykey)
WEATHER_SOJSON_URL = "http://t.weather.sojson.com/api/weather/city/{}"
# Weather API - uapis.cn (general weather interface)
WEATHER_UAPIS_URL = "https://uapis.cn/api/v1/misc/weather"
# Phone number lookup API
PHONE_INFO_URL = "https://uapis.cn/api/v1/misc/phoneinfo"
# IP Geolocation APIs
IP_MYIP_URL = "https://uapis.cn/api/v1/network/myip"
IP_INFO_URL = "https://uapis.cn/api/v1/network/ipinfo"
IP_SB_URL = "https://api.ip.sb/geoip/{}"
# ========== City Code Mapping ==========
CITY_CODE_MAP = {}
def load_city_codes():
"""Load city code mapping from adcode.txt"""
global CITY_CODE_MAP
try:
with open('adcode.txt', 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and '=' in line:
code, city = line.split('=', 1)
city_name = city.strip()
code_value = code.strip()
CITY_CODE_MAP[city_name] = code_value
print(f"Loaded {len(CITY_CODE_MAP)} city codes")
except FileNotFoundError:
print("Error: adcode.txt not found, city lookup unavailable")
raise SystemExit("Missing adcode.txt file")
except Exception as e:
print(f"Error loading adcode.txt: {e}")
raise
def search_city_code(query):
"""
Search for city code
1. Remove city/county/district suffixes and search
2. If multiple matches found, search again with suffix
3. If still multiple matches, return error
4. Single-character search not supported
"""
if not query or len(query.strip()) == 0:
return None, "City name cannot be empty"
query = query.strip()
# Single-character search not supported
if len(query) <= 1:
return None, "Single-character search not supported, please enter full city name"
# Remove suffix
clean_query = re.sub(r'[市县区州]$', '', query)
# First search: exact match on name without suffix
if clean_query in CITY_CODE_MAP:
return CITY_CODE_MAP[clean_query], None
# If suffix was removed and original query exists, try original
if clean_query != query and query in CITY_CODE_MAP:
return CITY_CODE_MAP[query], None
# Search for partial matches
matches = {}
for city, code in CITY_CODE_MAP.items():
# City name equals query without suffix
if city == clean_query:
matches[city] = code
# Query without suffix is a prefix of city name
elif city.startswith(clean_query):
# Require city to be at least 1 char longer than query to avoid vague matches
if len(city) - len(clean_query) >= 1:
matches[city] = code
# If first search found results
if matches:
if len(matches) == 1:
city, code = list(matches.items())[0]
return code, None
else:
# Multiple results, try adding original suffix back
if clean_query != query:
# Search for city names containing original query
refined_matches = {}
for city, code in CITY_CODE_MAP.items():
if query in city:
refined_matches[city] = code
if len(refined_matches) == 1:
city, code = list(refined_matches.items())[0]
return code, None
elif len(refined_matches) > 1:
cities = list(refined_matches.keys())
return None, f"Multiple cities found: {', '.join(cities)}, please provide a more specific city name"
else:
cities = list(matches.keys())
return None, f"Multiple cities found: {', '.join(cities)}, please provide a more specific city name"
else:
cities = list(matches.keys())
return None, f"Multiple cities found: {', '.join(cities)}, please provide a more specific city name"
# No match found
return None, f"City '{query}' not found"
# ========== Generic Request Function ==========
def safe_request(url, method='GET', params=None, timeout=10):
"""Safe HTTP request handler"""
try:
if method == 'GET':
resp = requests.get(url, params=params, timeout=timeout)
elif method == 'POST':
resp = requests.post(url, json=params, timeout=timeout)
else:
return None
if resp.status_code == 200:
return resp.json()
elif resp.status_code == 404:
return {"error": "not_found", "message": "Resource not found"}
else:
return {"error": f"HTTP {resp.status_code}", "message": resp.text}
except requests.exceptions.Timeout:
return {"error": "timeout", "message": "Request timeout"}
except requests.exceptions.ConnectionError:
return {"error": "connection_error", "message": "Connection failed"}
except requests.RequestException as e:
return {"error": "request_failed", "message": str(e)}
except json.JSONDecodeError:
return {"error": "json_decode_failed", "message": "Response parsing failed"}
# ========== Response Formatting ==========
def success_response(data, message="Success"):
"""Unified success response format"""
return jsonify({
"code": 200,
"message": message,
"data": data
})
def error_response(code, message, http_status=400):
"""Unified error response format"""
return jsonify({
"code": code,
"message": message
}), http_status
# ========== Helper: Get Local Time ==========
def get_local_time():
"""Get local time as fallback when external API fails"""
now = datetime.now()
weekdays_cn = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
weekdays_short = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
weekdays_en = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
return {
"timestamp": str(int(now.timestamp())),
"timestamp_ms": str(int(now.timestamp() * 1000)),
"datetime": now.strftime("%Y-%m-%d %H:%M:%S"),
"date_cn": now.strftime("%Y年%m月%d日"),
"date": now.strftime("%Y年%m月%d日"),
"time": now.strftime("%H:%M:%S"),
"week_num": str(now.isocalendar()[1]),
"week_cn": weekdays_cn[now.weekday()],
"week_short_cn": weekdays_short[now.weekday()],
"week_en": weekdays_en[now.weekday()]
}
# ========== API Routes ==========
@app.route('/api/v3/time', methods=['GET'])
def get_time():
"""
Get current time
GET /api/v3/time
"""
result = safe_request(TIME_API_URL)
if result and "date" in result and "time" in result:
try:
date_str = result.get("date", "")
time_str = result.get("time", "")
week_str = result.get("week", "")
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%Y年%m月%d日 %H:%M:%S")
timestamp = int(dt.timestamp())
timestamp_ms = int(dt.timestamp() * 1000)
datetime_iso = dt.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
now = datetime.now()
try:
year = int(re.search(r'(\d{4})年', date_str).group(1))
month = int(re.search(r'年(\d{2})月', date_str).group(1))
day = int(re.search(r'月(\d{2})日', date_str).group(1))
hour, minute, second = map(int, time_str.split(':'))
dt = datetime(year, month, day, hour, minute, second)
timestamp = int(dt.timestamp())
timestamp_ms = int(dt.timestamp() * 1000)
datetime_iso = dt.strftime("%Y-%m-%d %H:%M:%S")
except:
now = datetime.now()
timestamp = int(now.timestamp())
timestamp_ms = int(now.timestamp() * 1000)
datetime_iso = now.strftime("%Y-%m-%d %H:%M:%S")
weekdays_cn = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
weekdays_short = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
weekdays_en = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
weekday_index = 0
if week_str in weekdays_cn:
weekday_index = weekdays_cn.index(week_str)
else:
try:
weekday_index = dt.weekday()
except:
weekday_index = datetime.now().weekday()
formatted_data = {
"timestamp": str(timestamp),
"timestamp_ms": str(timestamp_ms),
"datetime": datetime_iso,
"date_cn": date_str,
"date": date_str,
"time": time_str,
"week_num": str(weekday_index + 1),
"week_cn": weekdays_cn[weekday_index],
"week_short_cn": weekdays_short[weekday_index],
"week_en": weekdays_en[weekday_index]
}
return success_response(formatted_data, "Time retrieved successfully")
except Exception as e:
print(f"Error parsing time response: {e}")
return success_response(get_local_time(), "Time retrieved from local system (fallback)")
print("Warning: Time API unavailable, using local time")
return success_response(get_local_time(), "Time retrieved from local system")
@app.route('/api/v3/weather/sojson', methods=['GET'])
def get_weather_sojson():
"""
Get weather (sojson API, uses citykey)
GET /api/v3/weather/sojson?city=Linyi
"""
city_name = request.args.get('city', '').strip()
if not city_name:
return error_response(400, "Please provide city name parameter ?city=city_name")
city_code, error_msg = search_city_code(city_name)
if error_msg:
return error_response(404, error_msg)
url = WEATHER_SOJSON_URL.format(city_code)
result = safe_request(url)
if result and result.get("status") == 200:
return success_response(result, "Weather retrieved successfully")
elif result and "error" in result:
return error_response(500, result.get("message", "Weather service error"), 500)
else:
return error_response(500, "Weather service temporarily unavailable", 500)
@app.route('/api/v3/weather', methods=['GET'])
def get_weather_uapis():
"""
Get weather (uapis.cn general weather API)
GET /api/v3/weather?city=Beijing
GET /api/v3/weather?adcode=110000
GET /api/v3/weather (auto IP geolocation)
"""
params = {}
for key in ['city', 'adcode', 'extended', 'forecast', 'hourly', 'minutely', 'indices', 'lang']:
value = request.args.get(key)
if value is not None:
if key in ['extended', 'forecast', 'hourly', 'minutely', 'indices']:
params[key] = value.lower() == 'true'
else:
params[key] = value
result = safe_request(WEATHER_UAPIS_URL, params=params)
if result:
if "error" in result:
return error_response(500, result.get("message", "Weather service error"), 500)
return success_response(result, "Weather retrieved successfully")
else:
return error_response(500, "Weather service temporarily unavailable", 500)
@app.route('/api/v3/phone', methods=['GET'])
def get_phone_info():
"""
Query phone number location
GET /api/v3/phone?phone=13800138000
"""
phone = request.args.get('phone', '').strip()
if not phone:
return error_response(400, "Please provide phone number parameter ?phone=phone_number")
if not re.match(r'^1[3-9]\d{9}$', phone):
return error_response(400, "Invalid phone number format, please enter an 11-digit Chinese mainland phone number")
result = safe_request(PHONE_INFO_URL, params={"phone": phone})
if result:
if "error" in result:
return error_response(400, result.get("message", "Query failed"), 400)
return success_response(result, "Phone number lookup successful")
else:
return error_response(500, "Phone number lookup service temporarily unavailable", 500)
# ========== IP Geolocation Routes ==========
@app.route('/api/v3/ip/me', methods=['GET'])
def get_my_ip():
"""
Get current client's public IP address
GET /api/v3/ip/me
GET /api/v3/ip/me?source=commercial (returns more detailed info)
"""
source = request.args.get('source', '')
params = {}
if source:
params['source'] = source
result = safe_request(IP_MYIP_URL, params=params if params else None)
if result and "ip" in result:
# Format the response
formatted_data = {
"ip": result.get("ip", ""),
"region": result.get("region", ""),
"isp": result.get("isp", ""),
"llc": result.get("llc", ""),
"asn": result.get("asn", ""),
"latitude": result.get("latitude", ""),
"longitude": result.get("longitude", ""),
"beginip": result.get("beginip", ""),
"endip": result.get("endip", "")
}
# Add commercial fields if available
if "district" in result:
formatted_data["district"] = result.get("district", "")
if "time_zone" in result:
formatted_data["time_zone"] = result.get("time_zone", "")
return success_response(formatted_data, "IP geolocation successful")
elif result and "error" in result:
return error_response(500, result.get("message", "IP service error"), 500)
else:
return error_response(500, "IP geolocation service temporarily unavailable", 500)
@app.route('/api/v3/ip', methods=['GET'])
def get_ip_info():
"""
Query IP information
GET /api/v3/ip?ip=8.8.8.8
GET /api/v3/ip?ip=8.8.8.8&source=commercial
GET /api/v3/ip (auto-detect current IP)
"""
ip = request.args.get('ip', '').strip()
source = request.args.get('source', '')
# If no IP provided, use myip endpoint
if not ip:
# Use IP_SB as fallback for auto-detection
try:
resp = requests.get("https://api.ip.sb/geoip", timeout=5)
if resp.status_code == 200:
data = resp.json()
formatted_data = {
"ip": data.get("ip", ""),
"region": f"{data.get('country', '')} {data.get('region', '')} {data.get('city', '')}".strip(),
"isp": data.get("isp", ""),
"organization": data.get("organization", ""),
"asn": data.get("asn", ""),
"latitude": data.get("latitude", ""),
"longitude": data.get("longitude", ""),
"country": data.get("country", ""),
"country_code": data.get("country_code", ""),
"region_code": data.get("region_code", ""),
"city": data.get("city", "")
}
return success_response(formatted_data, "IP geolocation successful")
except:
pass
# Fallback to uapis myip
result = safe_request(IP_MYIP_URL)
if result and "ip" in result:
formatted_data = {
"ip": result.get("ip", ""),
"region": result.get("region", ""),
"isp": result.get("isp", ""),
"llc": result.get("llc", ""),
"asn": result.get("asn", ""),
"latitude": result.get("latitude", ""),
"longitude": result.get("longitude", ""),
"beginip": result.get("beginip", ""),
"endip": result.get("endip", "")
}
return success_response(formatted_data, "IP geolocation successful")
return error_response(500, "Unable to determine IP address", 500)
# Query specific IP
# Try uapis first
params = {"ip": ip}
if source:
params["source"] = source
result = safe_request(IP_INFO_URL, params=params)
if result and "ip" in result:
formatted_data = {
"ip": result.get("ip", ""),
"region": result.get("region", ""),
"isp": result.get("isp", ""),
"llc": result.get("llc", ""),
"asn": result.get("asn", ""),
"latitude": result.get("latitude", ""),
"longitude": result.get("longitude", ""),
"beginip": result.get("beginip", ""),
"endip": result.get("endip", "")
}
return success_response(formatted_data, "IP geolocation successful")
# Fallback to ip.sb
try:
url = IP_SB_URL.format(ip)
resp = requests.get(url, timeout=5)
if resp.status_code == 200:
data = resp.json()
formatted_data = {
"ip": data.get("ip", ip),
"region": f"{data.get('country', '')} {data.get('region', '')} {data.get('city', '')}".strip(),
"isp": data.get("isp", ""),
"organization": data.get("organization", ""),
"asn": data.get("asn", ""),
"latitude": data.get("latitude", ""),
"longitude": data.get("longitude", ""),
"country": data.get("country", ""),
"country_code": data.get("country_code", ""),
"region_code": data.get("region_code", ""),
"city": data.get("city", "")
}
return success_response(formatted_data, "IP geolocation successful")
except:
pass
return error_response(500, "IP geolocation service temporarily unavailable", 500)
@app.route('/api/v3/ip/advanced', methods=['GET'])
def get_ip_advanced():
"""
Advanced IP query with commercial data source
GET /api/v3/ip/advanced?ip=8.8.8.8
"""
ip = request.args.get('ip', '').strip()
source = request.args.get('source', 'commercial')
if not ip:
return error_response(400, "Please provide IP address parameter ?ip=8.8.8.8")
params = {"ip": ip, "source": source}
result = safe_request(IP_INFO_URL, params=params)
if result and "ip" in result:
return success_response(result, "IP geolocation successful")
elif result and "error" in result:
return error_response(500, result.get("message", "IP service error"), 500)
else:
return error_response(500, "IP geolocation service temporarily unavailable", 500)
@app.route('/api/v3', methods=['GET'])
def api_index():
"""API directory"""
return jsonify({
"code": 200,
"message": "Unified API Gateway v3.0",
"version": "3.0.0",
"endpoints": {
"time": {
"path": "/api/v3/time",
"method": "GET",
"description": "Get current time",
"example": "/api/v3/time"
},
"weather_sojson": {
"path": "/api/v3/weather/sojson",
"method": "GET",
"description": "Get weather (sojson, uses citykey)",
"params": {"city": "City name"},
"example": "/api/v3/weather/sojson?city=Linyi"
},
"weather": {
"path": "/api/v3/weather",
"method": "GET",
"description": "Get weather (uapis.cn general API)",
"params": {"city": "City name", "forecast": "Enable forecast", "extended": "Enable extended fields"},
"example": "/api/v3/weather?city=Beijing&forecast=true"
},
"phone": {
"path": "/api/v3/phone",
"method": "GET",
"description": "Query phone number location",
"params": {"phone": "Phone number"},
"example": "/api/v3/phone?phone=13800138000"
},
"ip_me": {
"path": "/api/v3/ip/me",
"method": "GET",
"description": "Get your public IP address",
"params": {"source": "commercial (optional)"},
"example": "/api/v3/ip/me"
},
"ip": {
"path": "/api/v3/ip",
"method": "GET",
"description": "Query IP information",
"params": {"ip": "IP address (optional)", "source": "commercial (optional)"},
"example": "/api/v3/ip?ip=8.8.8.8"
},
"ip_advanced": {
"path": "/api/v3/ip/advanced",
"method": "GET",
"description": "Advanced IP query with commercial data",
"params": {"ip": "IP address", "source": "commercial (default)"},
"example": "/api/v3/ip/advanced?ip=8.8.8.8"
}
}
})
# ========== Error Handlers ==========
@app.errorhandler(404)
def not_found(error):
return jsonify({
"code": 404,
"message": "Endpoint not found, visit /api/v3 to see available endpoints"
}), 404
@app.errorhandler(500)
def server_error(error):
return jsonify({
"code": 500,
"message": "Internal server error"
}), 500
# ========== Startup ==========
if __name__ == '__main__':
load_city_codes()
app.run(host='0.0.0.0', port=880, debug=False)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DVS (dvs-dvsxt)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+149
View File
@@ -0,0 +1,149 @@
# 🌐 Unified API Gateway
> A unified API gateway providing **time, weather, phone number lookup, and IP geolocation** services via a consistent `/api/v3/` interface.
The **Unified API Gateway** wraps multiple external APIs behind a clean, unified `/api/v3/` REST interface. It provides current time, weather (by city / adcode / auto-IP), phone number location, and IP geolocation — all with consistent response formatting and graceful fallbacks.
---
## ✨ Features
| Endpoint | Description |
|----------|-------------|
| 🕐 **Time** | Get current time with timestamp / datetime / Chinese & English weekday |
| 🌤️ **Weather** | Weather by city name / adcode / auto-IP (sojson + uapis.cn dual source) |
| 📱 **Phone Lookup** | Query Chinese mainland phone number location & carrier |
| 🌍 **IP Geolocation** | Query IP info / your public IP / advanced commercial data |
| 🎯 **Precise City Matching** | Smart city name → adcode matching (from `adcode.txt`) |
| ⚡ **Graceful Fallback** | Automatic fallback when external APIs fail |
| 📦 **Unified Format** | Consistent `{code, message, data}` response structure |
---
## 🚀 Quick Start
### Prerequisites
- Python 3.8+
- `flask`, `requests`
### Install & Run
```bash
# Install dependencies
pip install flask requests
# Run the gateway (loads adcode.txt, listens on port 880)
python API.py
```
### Verify
```bash
# API directory / index
curl http://localhost:880/api/v3
# Current time
curl http://localhost:880/api/v3/time
# Weather by city
curl "http://localhost:880/api/v3/weather?city=Beijing"
# Weather by adcode
curl "http://localhost:880/api/v3/weather?adcode=110000"
# Phone number lookup
curl "http://localhost:880/api/v3/phone?phone=13800138000"
# Your public IP
curl "http://localhost:880/api/v3/ip/me"
# IP geolocation
curl "http://localhost:880/api/v3/ip?ip=8.8.8.8"
```
---
## 🔌 API Endpoints
### GET `/api/v3`
API directory / index — lists all available endpoints.
### GET `/api/v3/time`
Get current time.
- **Params**: none
- **Response**: timestamp, datetime, date (CN), time, week (CN/EN)
### GET `/api/v3/weather/sojson`
Get weather (sojson API, uses citykey).
- **Params**: `city` (city name, e.g. `Linyi`)
### GET `/api/v3/weather`
Get weather (uapis.cn general weather API).
- **Params**:
- `city` — city name
- `adcode` — administrative region code
- `forecast` / `extended` / `hourly` / `minutely` / `indices` — booleans
- `lang` — language
- **Note**: with no params, auto-detects by IP
### GET `/api/v3/phone`
Query phone number location.
- **Params**: `phone` — 11-digit Chinese mainland mobile number (validated `^1[3-9]\d{9}$`)
### GET `/api/v3/ip/me`
Get your public IP address.
- **Params**: `source=commercial` (optional, returns more detail)
### GET `/api/v3/ip`
Query IP information.
- **Params**: `ip` (IP address, optional — auto-detects if omitted), `source=commercial` (optional)
### GET `/api/v3/ip/advanced`
Advanced IP query with commercial data source.
- **Params**: `ip` (required), `source` (default `commercial`)
---
## 📦 Response Format
**Success:**
```json
{
"code": 200,
"message": "Success",
"data": { }
}
```
**Error:**
```json
{
"code": 404,
"message": "Error description"
}
```
---
## 🗂️ Project Structure
```
Unified-API-Gateway/
├── API.py # Flask API gateway (main program)
├── adcode.txt # City name → adcode mapping data
└── README.md # This document
```
---
## 📄 License
This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for details.
---
## 🙏 Credits
- Time API: [api.zxki.cn](https://api.zxki.cn)
- Weather APIs: [sojson](http://t.weather.sojson.com), [uapis.cn](https://uapis.cn)
- Phone / IP APIs: [uapis.cn](https://uapis.cn), [ip.sb](https://ip.sb)
+2621
View File
File diff suppressed because it is too large Load Diff