feat(api): add web search and geocoding
This commit is contained in:
@@ -49,3 +49,16 @@ KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
|||||||
KEYCLOAK_ALGORITHM=RS256
|
KEYCLOAK_ALGORITHM=RS256
|
||||||
KEYCLOAK_AUDIENCE="account"
|
KEYCLOAK_AUDIENCE="account"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Bocha Web Search API
|
||||||
|
# ============================================
|
||||||
|
BOCHA_API_KEY="sk-your-bocha-api-key"
|
||||||
|
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
|
||||||
|
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Tianditu Geocoding API
|
||||||
|
# ============================================
|
||||||
|
TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token"
|
||||||
|
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
|
||||||
|
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from app.services.geocoding import (
|
||||||
|
TiandituGeocodeRequest,
|
||||||
|
TiandituGeocodingAPIError,
|
||||||
|
TiandituGeocodingConfigError,
|
||||||
|
geocode_tianditu,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/tianditu/geocode",
|
||||||
|
summary="Tianditu Geocoding",
|
||||||
|
description="调用天地图地理编码服务,将结构化地址转换为经纬度",
|
||||||
|
)
|
||||||
|
async def tianditu_geocode(request: TiandituGeocodeRequest) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await geocode_tianditu(request)
|
||||||
|
except TiandituGeocodingConfigError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except TiandituGeocodingAPIError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from app.services.web_search import (
|
||||||
|
BochaSearchAPIError,
|
||||||
|
BochaSearchConfigError,
|
||||||
|
WebSearchRequest,
|
||||||
|
search_bocha_web,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/web-search",
|
||||||
|
summary="Web Search",
|
||||||
|
description="调用 Bocha Web Search API 获取实时网页搜索结果",
|
||||||
|
)
|
||||||
|
async def web_search(request: WebSearchRequest) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await search_bocha_web(request)
|
||||||
|
except BochaSearchConfigError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except BochaSearchAPIError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||||
@@ -17,6 +17,8 @@ from app.api.v1.endpoints import (
|
|||||||
user_management, # 新增:用户管理
|
user_management, # 新增:用户管理
|
||||||
audit, # 新增:审计日志
|
audit, # 新增:审计日志
|
||||||
meta,
|
meta,
|
||||||
|
web_search,
|
||||||
|
geocoding,
|
||||||
)
|
)
|
||||||
from app.api.v1.endpoints.network import (
|
from app.api.v1.endpoints.network import (
|
||||||
general,
|
general,
|
||||||
@@ -89,6 +91,8 @@ api_router.include_router(schemes.router, tags=["Schemes"])
|
|||||||
api_router.include_router(misc.router, tags=["Misc"])
|
api_router.include_router(misc.router, tags=["Misc"])
|
||||||
api_router.include_router(risk.router, tags=["Risk"])
|
api_router.include_router(risk.router, tags=["Risk"])
|
||||||
api_router.include_router(cache.router, tags=["Cache"])
|
api_router.include_router(cache.router, tags=["Cache"])
|
||||||
|
api_router.include_router(web_search.router, tags=["Web Search"])
|
||||||
|
api_router.include_router(geocoding.router, tags=["Geocoding"])
|
||||||
api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"])
|
api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"])
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
|
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ class Settings(BaseSettings):
|
|||||||
KEYCLOAK_ALGORITHM: str = "RS256"
|
KEYCLOAK_ALGORITHM: str = "RS256"
|
||||||
KEYCLOAK_AUDIENCE: str = ""
|
KEYCLOAK_AUDIENCE: str = ""
|
||||||
|
|
||||||
|
# Bocha Web Search API
|
||||||
|
BOCHA_API_KEY: str = ""
|
||||||
|
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
|
||||||
|
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0
|
||||||
|
|
||||||
|
# Tianditu Geocoding API
|
||||||
|
TIANDITU_GEOCODER_TOKEN: str = ""
|
||||||
|
TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder"
|
||||||
|
TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
||||||
db_password = quote_plus(self.DB_PASSWORD)
|
db_password = quote_plus(self.DB_PASSWORD)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import AliasChoices, BaseModel, Field
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class TiandituGeocodeRequest(BaseModel):
|
||||||
|
keyword: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
validation_alias=AliasChoices("keyword", "keyWord"),
|
||||||
|
description="地理编码地址关键字",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TiandituGeocodingConfigError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TiandituGeocodingAPIError(RuntimeError):
|
||||||
|
def __init__(self, status_code: int, detail: Any):
|
||||||
|
super().__init__("Tianditu Geocoding API request failed")
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
async def geocode_tianditu(
|
||||||
|
request: TiandituGeocodeRequest,
|
||||||
|
*,
|
||||||
|
client: httpx.AsyncClient | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not settings.TIANDITU_GEOCODER_TOKEN:
|
||||||
|
raise TiandituGeocodingConfigError("TIANDITU_GEOCODER_TOKEN is not configured")
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"ds": json.dumps({"keyWord": request.keyword}, ensure_ascii=False),
|
||||||
|
"tk": settings.TIANDITU_GEOCODER_TOKEN,
|
||||||
|
}
|
||||||
|
|
||||||
|
if client is not None:
|
||||||
|
response = await client.get(settings.TIANDITU_GEOCODER_URL, params=params)
|
||||||
|
return _parse_response(response)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=settings.TIANDITU_GEOCODER_TIMEOUT_SECONDS
|
||||||
|
) as managed_client:
|
||||||
|
response = await managed_client.get(
|
||||||
|
settings.TIANDITU_GEOCODER_URL,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
return _parse_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(response: httpx.Response) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise TiandituGeocodingAPIError(
|
||||||
|
exc.response.status_code,
|
||||||
|
_response_detail(exc.response),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if str(data.get("status")) != "0":
|
||||||
|
raise TiandituGeocodingAPIError(502, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _response_detail(response: httpx.Response) -> Any:
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError:
|
||||||
|
return response.text
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
Freshness = Literal["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"]
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchRequest(BaseModel):
|
||||||
|
query: str = Field(..., min_length=1, description="搜索关键词")
|
||||||
|
freshness: Freshness | str = Field(
|
||||||
|
default="noLimit",
|
||||||
|
description="时间范围:noLimit、oneDay、oneWeek、oneMonth、oneYear 或日期范围",
|
||||||
|
)
|
||||||
|
summary: bool = Field(default=True, description="是否返回网页摘要")
|
||||||
|
count: int = Field(default=10, ge=1, le=50, description="返回结果数量")
|
||||||
|
include: list[str] | None = Field(default=None, description="限定搜索域名")
|
||||||
|
exclude: list[str] | None = Field(default=None, description="排除搜索域名")
|
||||||
|
|
||||||
|
|
||||||
|
class BochaSearchConfigError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BochaSearchAPIError(RuntimeError):
|
||||||
|
def __init__(self, status_code: int, detail: Any):
|
||||||
|
super().__init__("Bocha Web Search API request failed")
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
def _build_payload(request: WebSearchRequest) -> dict[str, Any]:
|
||||||
|
payload = request.model_dump(exclude_none=True)
|
||||||
|
if request.include:
|
||||||
|
payload["include"] = ",".join(request.include)
|
||||||
|
if request.exclude:
|
||||||
|
payload["exclude"] = ",".join(request.exclude)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def search_bocha_web(
|
||||||
|
request: WebSearchRequest,
|
||||||
|
*,
|
||||||
|
client: httpx.AsyncClient | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not settings.BOCHA_API_KEY:
|
||||||
|
raise BochaSearchConfigError("BOCHA_API_KEY is not configured")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {settings.BOCHA_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = _build_payload(request)
|
||||||
|
|
||||||
|
if client is not None:
|
||||||
|
response = await client.post(
|
||||||
|
settings.BOCHA_WEB_SEARCH_URL,
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
return _parse_response(response)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=settings.BOCHA_WEB_SEARCH_TIMEOUT_SECONDS
|
||||||
|
) as managed_client:
|
||||||
|
response = await managed_client.post(
|
||||||
|
settings.BOCHA_WEB_SEARCH_URL,
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
return _parse_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(response: httpx.Response) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise BochaSearchAPIError(
|
||||||
|
exc.response.status_code,
|
||||||
|
_response_detail(exc.response),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _response_detail(response: httpx.Response) -> Any:
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError:
|
||||||
|
return response.text
|
||||||
Reference in New Issue
Block a user