Improve global market dashboard UX and settings
- Add global market dashboard APIs/assets and improve data robustness (incl. crypto heatmap by market cap) - Enhance global market UI (map+heatmap layout, loading behavior, formatting, theme tweaks) - Fix Settings LLM Provider select to render label/value options correctly - Rename Indicator Community to Official Community and move it to the bottom - Add search fallback when Google quota is exhausted
This commit is contained in:
@@ -22,6 +22,7 @@ def register_routes(app: Flask):
|
||||
from app.routes.ibkr import ibkr_bp
|
||||
from app.routes.mt5 import mt5_bp
|
||||
from app.routes.user import user_bp
|
||||
from app.routes.global_market import global_market_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
|
||||
@@ -39,3 +40,4 @@ def register_routes(app: Flask):
|
||||
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
|
||||
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
|
||||
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
|
||||
app.register_blueprint(global_market_bp, url_prefix='/api/global-market')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,7 @@ def get_public_config():
|
||||
'google/gemini-2.5-pro': 'Google: Gemini 2.5 Pro',
|
||||
'openai/gpt-4o-mini': 'OpenAI: GPT-4o-mini',
|
||||
'openai/gpt-5-mini': 'OpenAI: GPT-5 Mini',
|
||||
'openai/gpt-oss-120b': 'OpenAI: gpt-oss-120b',
|
||||
'openai/gpt-4.1-mini': 'OpenAI: GPT-4.1 Mini',
|
||||
'deepseek/deepseek-v3.2': 'DeepSeek: DeepSeek V3.2',
|
||||
'minimax/minimax-m2': 'MiniMax: MiniMax M2',
|
||||
'anthropic/claude-sonnet-4': 'Anthropic: Claude Sonnet 4',
|
||||
|
||||
@@ -387,8 +387,9 @@ class LLMService:
|
||||
logger.error(f"{p.value} API HTTP error ({current_model}): {error_detail}")
|
||||
last_error = str(e)
|
||||
|
||||
# Check for payment/quota errors
|
||||
if e.response and e.response.status_code in (402, 429):
|
||||
# Check for recoverable errors - try fallback model
|
||||
# 402: Payment required, 403: Forbidden (invalid key), 404: Model not found, 429: Rate limit
|
||||
if e.response and e.response.status_code in (402, 403, 404, 429):
|
||||
logger.warning(f"{p.value} returned {e.response.status_code} for model {current_model}; trying fallback...")
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
"""
|
||||
Search service.
|
||||
Integrates Google Custom Search (CSE) and Bing Search API.
|
||||
Integrates Google Custom Search (CSE), Bing Search API, and DuckDuckGo (free fallback).
|
||||
Configuration is provided via environment variables (see env.example) through config_loader.
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
from typing import List, Dict, Any, Optional
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.config_loader import load_addon_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Track Google API quota status
|
||||
_google_quota_exhausted = False
|
||||
_google_quota_reset_time = 0
|
||||
|
||||
|
||||
class SearchService:
|
||||
"""Search service."""
|
||||
"""Search service with automatic fallback."""
|
||||
|
||||
def __init__(self):
|
||||
self._config = {}
|
||||
@@ -28,7 +33,7 @@ class SearchService:
|
||||
|
||||
def search(self, query: str, num_results: int = None, date_restrict: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute a web search.
|
||||
Execute a web search with automatic fallback.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
@@ -38,18 +43,45 @@ class SearchService:
|
||||
Returns:
|
||||
List of search results
|
||||
"""
|
||||
global _google_quota_exhausted, _google_quota_reset_time
|
||||
|
||||
# 重新加载配置以支持热更新
|
||||
self._load_config()
|
||||
|
||||
limit = num_results if num_results else self.max_results
|
||||
|
||||
# Check if Google quota has reset (after midnight UTC typically)
|
||||
if _google_quota_exhausted and time.time() > _google_quota_reset_time:
|
||||
_google_quota_exhausted = False
|
||||
logger.info("Google API quota reset, re-enabling Google search")
|
||||
|
||||
results = []
|
||||
|
||||
if self.provider == 'bing':
|
||||
return self._search_bing(query, limit)
|
||||
results = self._search_bing(query, limit)
|
||||
elif self.provider == 'duckduckgo':
|
||||
results = self._search_duckduckgo(query, limit)
|
||||
else:
|
||||
return self._search_google(query, limit, date_restrict)
|
||||
# Google with fallback
|
||||
if not _google_quota_exhausted:
|
||||
results = self._search_google(query, limit, date_restrict)
|
||||
|
||||
# If Google failed or returned empty, try fallbacks
|
||||
if not results:
|
||||
logger.info("Google search failed or empty, trying fallback search engines...")
|
||||
# Try Bing first if configured
|
||||
results = self._search_bing(query, limit)
|
||||
|
||||
# If Bing also failed, try DuckDuckGo (free, no API key needed)
|
||||
if not results:
|
||||
results = self._search_duckduckgo(query, limit)
|
||||
|
||||
return results
|
||||
|
||||
def _search_google(self, query: str, num_results: int, date_restrict: str = None) -> List[Dict[str, Any]]:
|
||||
"""Google Custom Search (CSE)."""
|
||||
global _google_quota_exhausted, _google_quota_reset_time
|
||||
|
||||
api_key = self._config.get('google', {}).get('api_key')
|
||||
cx = self._config.get('google', {}).get('cx')
|
||||
|
||||
@@ -71,17 +103,23 @@ class SearchService:
|
||||
params['dateRestrict'] = date_restrict
|
||||
|
||||
try:
|
||||
# logger.info(f"正在调用 Google Search API: q={query}, params={params}")
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
|
||||
# Check for quota exceeded (429)
|
||||
if response.status_code == 429:
|
||||
logger.warning("Google Search API quota exceeded (429). Switching to fallback search engines.")
|
||||
_google_quota_exhausted = True
|
||||
# Set reset time to next day midnight UTC
|
||||
import datetime
|
||||
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
|
||||
_google_quota_reset_time = tomorrow.timestamp()
|
||||
return []
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)}") # 打印全部字符
|
||||
# logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)[:500]}...") # 打印前500字符避免日志过大
|
||||
|
||||
results = []
|
||||
if 'items' in data:
|
||||
# logger.info(f"Google Search 返回了 {len(data['items'])} 条结果")
|
||||
for item in data['items']:
|
||||
logger.debug(f"Search Item: {item.get('title')} - {item.get('link')}")
|
||||
results.append({
|
||||
@@ -96,10 +134,20 @@ class SearchService:
|
||||
|
||||
return results
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 429:
|
||||
logger.warning("Google Search API quota exceeded. Switching to fallback.")
|
||||
_google_quota_exhausted = True
|
||||
import datetime
|
||||
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
|
||||
_google_quota_reset_time = tomorrow.timestamp()
|
||||
else:
|
||||
logger.error(f"Google search failed: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
logger.error(f"Response: {e.response.text}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Google search failed: {e}")
|
||||
if 'response' in locals():
|
||||
logger.error(f"Response: {response.text}")
|
||||
return []
|
||||
|
||||
def _search_bing(self, query: str, num_results: int) -> List[Dict[str, Any]]:
|
||||
@@ -140,3 +188,120 @@ class SearchService:
|
||||
logger.error(f"Bing search failed: {e}")
|
||||
return []
|
||||
|
||||
def _search_duckduckgo(self, query: str, num_results: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
DuckDuckGo search (free, no API key required).
|
||||
Uses the DuckDuckGo HTML search endpoint.
|
||||
"""
|
||||
try:
|
||||
# Use DuckDuckGo Instant Answer API
|
||||
url = "https://api.duckduckgo.com/"
|
||||
params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'no_html': 1,
|
||||
'skip_disambig': 1
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = []
|
||||
|
||||
# Get results from RelatedTopics
|
||||
related_topics = data.get('RelatedTopics', [])
|
||||
for topic in related_topics[:num_results]:
|
||||
if isinstance(topic, dict):
|
||||
if 'FirstURL' in topic:
|
||||
results.append({
|
||||
'title': topic.get('Text', '')[:100],
|
||||
'link': topic.get('FirstURL', ''),
|
||||
'snippet': topic.get('Text', ''),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
# Handle nested topics
|
||||
elif 'Topics' in topic:
|
||||
for sub_topic in topic['Topics']:
|
||||
if len(results) >= num_results:
|
||||
break
|
||||
if 'FirstURL' in sub_topic:
|
||||
results.append({
|
||||
'title': sub_topic.get('Text', '')[:100],
|
||||
'link': sub_topic.get('FirstURL', ''),
|
||||
'snippet': sub_topic.get('Text', ''),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
|
||||
# Also check AbstractURL and AbstractText
|
||||
if data.get('AbstractURL') and len(results) < num_results:
|
||||
results.insert(0, {
|
||||
'title': data.get('Heading', query),
|
||||
'link': data.get('AbstractURL', ''),
|
||||
'snippet': data.get('AbstractText', ''),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
|
||||
# If no results from Instant Answer, try HTML scraping as fallback
|
||||
if not results:
|
||||
results = self._search_duckduckgo_html(query, num_results)
|
||||
|
||||
if results:
|
||||
logger.info(f"DuckDuckGo search returned {len(results)} results")
|
||||
|
||||
return results[:num_results]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DuckDuckGo search failed: {e}")
|
||||
# Try HTML fallback
|
||||
return self._search_duckduckgo_html(query, num_results)
|
||||
|
||||
def _search_duckduckgo_html(self, query: str, num_results: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
DuckDuckGo HTML search fallback.
|
||||
Scrapes the lite HTML version for better results.
|
||||
"""
|
||||
try:
|
||||
url = "https://lite.duckduckgo.com/lite/"
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
data = {'q': query}
|
||||
|
||||
response = requests.post(url, headers=headers, data=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
results = []
|
||||
|
||||
# Simple HTML parsing without BeautifulSoup
|
||||
html = response.text
|
||||
|
||||
# Find all result links (they have class="result-link")
|
||||
import re
|
||||
|
||||
# Pattern to find result entries
|
||||
link_pattern = r'<a[^>]*class="result-link"[^>]*href="([^"]*)"[^>]*>([^<]*)</a>'
|
||||
snippet_pattern = r'<td[^>]*class="result-snippet"[^>]*>([^<]*)</td>'
|
||||
|
||||
links = re.findall(link_pattern, html)
|
||||
snippets = re.findall(snippet_pattern, html)
|
||||
|
||||
for i, (link, title) in enumerate(links[:num_results]):
|
||||
snippet = snippets[i] if i < len(snippets) else ''
|
||||
if link and title:
|
||||
results.append({
|
||||
'title': title.strip(),
|
||||
'link': link,
|
||||
'snippet': snippet.strip(),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DuckDuckGo HTML search failed: {e}")
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user