search-api-webui 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- search_api_webui/__init__.py +0 -1
- search_api_webui/app.py +142 -47
- search_api_webui/providers/__init__.py +10 -7
- search_api_webui/providers/base.py +65 -5
- search_api_webui/providers/generic.py +70 -43
- search_api_webui/providers/querit.py +42 -33
- search_api_webui/providers.yaml +62 -4
- search_api_webui/ruff.toml +27 -0
- search_api_webui/static/AppIcon.icns +0 -0
- search_api_webui/static/assets/{index-Co_gG-wr.js → index-7iEn12Q9.js} +39 -39
- search_api_webui/static/assets/index-DKxNoLrm.css +1 -0
- search_api_webui/static/index.html +2 -2
- {search_api_webui-0.1.6.dist-info → search_api_webui-0.1.8.dist-info}/METADATA +34 -12
- search_api_webui-0.1.8.dist-info/RECORD +18 -0
- search_api_webui/static/assets/index-BNPOSv2e.css +0 -1
- search_api_webui-0.1.6.dist-info/RECORD +0 -16
- {search_api_webui-0.1.6.dist-info → search_api_webui-0.1.8.dist-info}/WHEEL +0 -0
- {search_api_webui-0.1.6.dist-info → search_api_webui-0.1.8.dist-info}/entry_points.txt +0 -0
- {search_api_webui-0.1.6.dist-info → search_api_webui-0.1.8.dist-info}/licenses/LICENSE +0 -0
search_api_webui/__init__.py
CHANGED
search_api_webui/app.py
CHANGED
|
@@ -19,25 +19,55 @@
|
|
|
19
19
|
# DEALINGS IN THE SOFTWARE.
|
|
20
20
|
|
|
21
21
|
import json
|
|
22
|
-
import
|
|
22
|
+
import socket
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
23
26
|
import webbrowser
|
|
24
27
|
from pathlib import Path
|
|
25
|
-
|
|
28
|
+
|
|
29
|
+
from flask import Flask, jsonify, request, send_from_directory
|
|
26
30
|
from flask_cors import CORS
|
|
31
|
+
|
|
27
32
|
from search_api_webui.providers import load_providers
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
try:
|
|
35
|
+
import webview
|
|
36
|
+
WEBVIEW_AVAILABLE = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
WEBVIEW_AVAILABLE = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_resource_path(relative_path):
|
|
42
|
+
'''Get absolute path to resource, works for dev and for PyInstaller.'''
|
|
43
|
+
try:
|
|
44
|
+
# PyInstaller creates a temp folder and stores path in _MEIPASS
|
|
45
|
+
base_path = Path(sys._MEIPASS)
|
|
46
|
+
except Exception:
|
|
47
|
+
base_path = Path(__file__).resolve().parent
|
|
48
|
+
|
|
49
|
+
return base_path / relative_path
|
|
30
50
|
|
|
31
|
-
STATIC_FOLDER = CURRENT_DIR / 'static'
|
|
32
|
-
if not STATIC_FOLDER.exists():
|
|
33
|
-
DEV_FRONTEND_DIST = CURRENT_DIR.parent / 'frontend' / 'dist'
|
|
34
|
-
if DEV_FRONTEND_DIST.exists():
|
|
35
|
-
STATIC_FOLDER = DEV_FRONTEND_DIST
|
|
36
51
|
|
|
37
|
-
|
|
52
|
+
CURRENT_DIR = Path(__file__).resolve().parent
|
|
53
|
+
|
|
54
|
+
# Handle static folder for both dev and packaged app
|
|
55
|
+
if hasattr(sys, '_MEIPASS'):
|
|
56
|
+
# Running in PyInstaller bundle
|
|
57
|
+
STATIC_FOLDER = Path(sys._MEIPASS) / 'static'
|
|
58
|
+
else:
|
|
59
|
+
# Running in development
|
|
60
|
+
STATIC_FOLDER = CURRENT_DIR / 'static'
|
|
61
|
+
if not STATIC_FOLDER.exists():
|
|
62
|
+
DEV_FRONTEND_DIST = CURRENT_DIR.parent / 'frontend' / 'dist'
|
|
63
|
+
if DEV_FRONTEND_DIST.exists():
|
|
64
|
+
STATIC_FOLDER = DEV_FRONTEND_DIST
|
|
65
|
+
|
|
66
|
+
app = Flask(__name__, static_folder=str(STATIC_FOLDER))
|
|
38
67
|
CORS(app)
|
|
39
68
|
|
|
40
|
-
|
|
69
|
+
# Use get_resource_path for providers.yaml
|
|
70
|
+
PROVIDERS_YAML = get_resource_path('providers.yaml')
|
|
41
71
|
USER_CONFIG_DIR = Path.home() / '.search-api-webui'
|
|
42
72
|
USER_CONFIG_JSON = USER_CONFIG_DIR / 'config.json'
|
|
43
73
|
|
|
@@ -47,25 +77,28 @@ if not USER_CONFIG_DIR.exists():
|
|
|
47
77
|
if PROVIDERS_YAML.exists():
|
|
48
78
|
provider_map = load_providers(str(PROVIDERS_YAML))
|
|
49
79
|
else:
|
|
50
|
-
print(f
|
|
80
|
+
print(f'Error: Configuration file not found at {PROVIDERS_YAML}')
|
|
51
81
|
provider_map = {}
|
|
52
82
|
|
|
83
|
+
|
|
53
84
|
def get_stored_config():
|
|
54
85
|
if not USER_CONFIG_JSON.exists():
|
|
55
86
|
return {}
|
|
56
87
|
try:
|
|
57
|
-
with open(USER_CONFIG_JSON,
|
|
88
|
+
with open(USER_CONFIG_JSON, encoding='utf-8') as f:
|
|
58
89
|
return json.load(f)
|
|
59
90
|
except Exception as e:
|
|
60
|
-
print(f
|
|
91
|
+
print(f'Error reading config: {e}')
|
|
61
92
|
return {}
|
|
62
93
|
|
|
94
|
+
|
|
63
95
|
def save_stored_config(config_dict):
|
|
64
96
|
try:
|
|
65
97
|
with open(USER_CONFIG_JSON, 'w', encoding='utf-8') as f:
|
|
66
98
|
json.dump(config_dict, f, indent=2)
|
|
67
99
|
except Exception as e:
|
|
68
|
-
print(f
|
|
100
|
+
print(f'Error saving config: {e}')
|
|
101
|
+
|
|
69
102
|
|
|
70
103
|
@app.route('/api/providers', methods=['GET'])
|
|
71
104
|
def get_providers_list():
|
|
@@ -76,34 +109,37 @@ def get_providers_list():
|
|
|
76
109
|
config_details = provider_instance.config
|
|
77
110
|
|
|
78
111
|
user_conf = stored_config.get(name, {})
|
|
79
|
-
|
|
112
|
+
|
|
80
113
|
if isinstance(user_conf, str):
|
|
81
114
|
user_conf = {'api_key': user_conf}
|
|
82
115
|
|
|
83
116
|
has_key = bool(user_conf.get('api_key'))
|
|
84
117
|
|
|
85
|
-
providers_info.append(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
providers_info.append(
|
|
119
|
+
{
|
|
120
|
+
'name': name,
|
|
121
|
+
'has_key': has_key,
|
|
122
|
+
'details': config_details,
|
|
123
|
+
'user_settings': {
|
|
124
|
+
'api_url': user_conf.get('api_url', ''),
|
|
125
|
+
'limit': user_conf.get('limit', '10'),
|
|
126
|
+
'language': user_conf.get('language', 'en-US'),
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
)
|
|
95
130
|
return jsonify(providers_info)
|
|
96
131
|
|
|
132
|
+
|
|
97
133
|
@app.route('/api/config', methods=['POST'])
|
|
98
134
|
def update_config():
|
|
99
135
|
data = request.json
|
|
100
136
|
provider_name = data.get('provider')
|
|
101
137
|
|
|
102
138
|
if not provider_name:
|
|
103
|
-
return jsonify({
|
|
139
|
+
return jsonify({'error': 'Provider name is required'}), 400
|
|
104
140
|
|
|
105
141
|
if 'api_key' not in data:
|
|
106
|
-
return jsonify({
|
|
142
|
+
return jsonify({'error': 'API Key field is missing'}), 400
|
|
107
143
|
|
|
108
144
|
api_key = data.get('api_key')
|
|
109
145
|
|
|
@@ -118,7 +154,7 @@ def update_config():
|
|
|
118
154
|
|
|
119
155
|
if not api_key:
|
|
120
156
|
if provider_name in all_config:
|
|
121
|
-
all_config[provider_name]['api_key'] =
|
|
157
|
+
all_config[provider_name]['api_key'] = ''
|
|
122
158
|
else:
|
|
123
159
|
if provider_name not in all_config:
|
|
124
160
|
all_config[provider_name] = {}
|
|
@@ -129,7 +165,8 @@ def update_config():
|
|
|
129
165
|
all_config[provider_name]['language'] = language
|
|
130
166
|
|
|
131
167
|
save_stored_config(all_config)
|
|
132
|
-
return jsonify({
|
|
168
|
+
return jsonify({'status': 'success'})
|
|
169
|
+
|
|
133
170
|
|
|
134
171
|
@app.route('/api/search', methods=['POST'])
|
|
135
172
|
def search_api():
|
|
@@ -149,46 +186,104 @@ def search_api():
|
|
|
149
186
|
api_key = provider_config.get('api_key')
|
|
150
187
|
|
|
151
188
|
if not api_key:
|
|
152
|
-
return
|
|
189
|
+
return (
|
|
190
|
+
jsonify({'error': f'API Key for {provider_name} is missing. Please configure it.'}),
|
|
191
|
+
401,
|
|
192
|
+
)
|
|
153
193
|
|
|
154
194
|
provider = provider_map.get(provider_name)
|
|
155
195
|
if not provider:
|
|
156
|
-
return jsonify({
|
|
196
|
+
return jsonify({'error': 'Provider not found'}), 404
|
|
157
197
|
|
|
158
198
|
search_kwargs = {
|
|
159
199
|
'api_url': provider_config.get('api_url'),
|
|
160
200
|
'limit': provider_config.get('limit'),
|
|
161
|
-
'language': provider_config.get('language')
|
|
201
|
+
'language': provider_config.get('language'),
|
|
162
202
|
}
|
|
163
203
|
|
|
164
204
|
result = provider.search(query, api_key, **search_kwargs)
|
|
165
205
|
return jsonify(result)
|
|
166
206
|
|
|
207
|
+
|
|
167
208
|
# Host React Frontend
|
|
168
209
|
@app.route('/', defaults={'path': ''})
|
|
169
210
|
@app.route('/<path:path>')
|
|
170
211
|
def serve(path):
|
|
171
|
-
if path !=
|
|
212
|
+
if path != '' and (STATIC_FOLDER / path).exists():
|
|
172
213
|
return send_from_directory(str(STATIC_FOLDER), path)
|
|
173
214
|
else:
|
|
174
215
|
return send_from_directory(str(STATIC_FOLDER), 'index.html')
|
|
175
216
|
|
|
176
|
-
def main():
|
|
177
|
-
import argparse
|
|
178
|
-
parser = argparse.ArgumentParser(description="Search API WebUI")
|
|
179
|
-
parser.add_argument("--port", type=int, default=8889, help="Port to run the server on")
|
|
180
|
-
parser.add_argument("--host", type=str, default="127.0.0.1", help="Host to run the server on")
|
|
181
|
-
args = parser.parse_args()
|
|
182
217
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
218
|
+
def wait_for_server_ready(host, port):
|
|
219
|
+
start_time = time.time()
|
|
220
|
+
while time.time() - start_time < 10:
|
|
221
|
+
try:
|
|
222
|
+
with socket.create_connection((host, port), timeout=1):
|
|
223
|
+
return True
|
|
224
|
+
except (OSError, ConnectionRefusedError):
|
|
225
|
+
time.sleep(0.1)
|
|
226
|
+
return False
|
|
227
|
+
|
|
187
228
|
|
|
188
|
-
|
|
189
|
-
|
|
229
|
+
def main():
|
|
230
|
+
import argparse
|
|
190
231
|
|
|
191
|
-
|
|
232
|
+
parser = argparse.ArgumentParser(description='Search API WebUI')
|
|
233
|
+
parser.add_argument('--port', type=int, default=8889, help='Port to run the server on')
|
|
234
|
+
parser.add_argument('--host', type=str, default='127.0.0.1', help='Host to run the server on')
|
|
235
|
+
parser.add_argument('-w', '--webview', action='store_true', help='Use webview to open the application')
|
|
236
|
+
args = parser.parse_args()
|
|
192
237
|
|
|
193
|
-
|
|
238
|
+
url = f'http://{args.host}:{args.port}'
|
|
239
|
+
print('Starting Search API WebUI...')
|
|
240
|
+
print(f' - Config Storage: {USER_CONFIG_JSON}')
|
|
241
|
+
print(f' - Serving on: {url}')
|
|
242
|
+
if args.webview:
|
|
243
|
+
print(' - Mode: webview')
|
|
244
|
+
|
|
245
|
+
if args.webview:
|
|
246
|
+
if not WEBVIEW_AVAILABLE:
|
|
247
|
+
print('Warning: webview library not installed. Falling back to webbrowser.')
|
|
248
|
+
# Start server in background thread and wait for it to be ready
|
|
249
|
+
server_thread = threading.Thread(
|
|
250
|
+
target=lambda: app.run(
|
|
251
|
+
host=args.host, port=args.port, use_reloader=False,
|
|
252
|
+
),
|
|
253
|
+
daemon=True,
|
|
254
|
+
)
|
|
255
|
+
server_thread.start()
|
|
256
|
+
if wait_for_server_ready(args.host, args.port):
|
|
257
|
+
print(f'Server is ready! Opening browser: {url}')
|
|
258
|
+
webbrowser.open(url)
|
|
259
|
+
else:
|
|
260
|
+
print('Error: Server took too long to start. Browser not opened.')
|
|
261
|
+
else:
|
|
262
|
+
# Start server in background thread and wait for it to be ready, then start webview
|
|
263
|
+
server_thread = threading.Thread(
|
|
264
|
+
target=lambda: app.run(
|
|
265
|
+
host=args.host, port=args.port, use_reloader=False,
|
|
266
|
+
),
|
|
267
|
+
daemon=True,
|
|
268
|
+
)
|
|
269
|
+
server_thread.start()
|
|
270
|
+
if wait_for_server_ready(args.host, args.port):
|
|
271
|
+
print('Server is ready! Using webview mode...')
|
|
272
|
+
webview.create_window('Search API WebUI', url, width=1200, height=800)
|
|
273
|
+
webview.start()
|
|
274
|
+
else:
|
|
275
|
+
print('Error: Server took too long to start. Webview not opened.')
|
|
276
|
+
else:
|
|
277
|
+
# Start a background thread to check server status and open the browser automatically
|
|
278
|
+
def open_browser():
|
|
279
|
+
if wait_for_server_ready(args.host, args.port):
|
|
280
|
+
print(f'Server is ready! Opening browser: {url}')
|
|
281
|
+
webbrowser.open(url)
|
|
282
|
+
else:
|
|
283
|
+
print('Error: Server took too long to start. Browser not opened.')
|
|
284
|
+
threading.Thread(target=open_browser, daemon=True).start()
|
|
285
|
+
app.run(host=args.host, port=args.port)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == '__main__':
|
|
194
289
|
main()
|
|
@@ -19,25 +19,28 @@
|
|
|
19
19
|
# DEALINGS IN THE SOFTWARE.
|
|
20
20
|
|
|
21
21
|
import os
|
|
22
|
+
|
|
22
23
|
import yaml
|
|
24
|
+
|
|
23
25
|
from .generic import GenericProvider
|
|
24
26
|
from .querit import QueritSdkProvider
|
|
25
27
|
|
|
28
|
+
|
|
26
29
|
def load_providers(file_path='providers.yaml'):
|
|
27
|
-
|
|
30
|
+
'''
|
|
28
31
|
Parses the YAML configuration file and instantiates the appropriate provider classes.
|
|
29
|
-
|
|
32
|
+
|
|
30
33
|
Args:
|
|
31
34
|
file_path (str): Path to the providers configuration file.
|
|
32
|
-
|
|
35
|
+
|
|
33
36
|
Returns:
|
|
34
37
|
dict: A dictionary mapping provider names to their initialized instances.
|
|
35
|
-
|
|
38
|
+
'''
|
|
36
39
|
if not os.path.exists(file_path):
|
|
37
|
-
print(f
|
|
40
|
+
print(f'Warning: Provider config file not found at {file_path}')
|
|
38
41
|
return {}
|
|
39
|
-
|
|
40
|
-
with open(file_path,
|
|
42
|
+
|
|
43
|
+
with open(file_path, encoding='utf-8') as f:
|
|
41
44
|
configs = yaml.safe_load(f)
|
|
42
45
|
|
|
43
46
|
providers = {}
|
|
@@ -20,15 +20,74 @@
|
|
|
20
20
|
|
|
21
21
|
from abc import ABC, abstractmethod
|
|
22
22
|
|
|
23
|
+
|
|
24
|
+
def parse_server_latency(latency_value):
|
|
25
|
+
'''
|
|
26
|
+
Parse server latency value from various formats to milliseconds.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
latency_value: The latency value to parse. Can be:
|
|
30
|
+
- int/float: A numeric value
|
|
31
|
+
- str: A string with or without unit suffix (e.g., "123ms", "0.123s", "123")
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
float | None: The latency in milliseconds, or None if parsing fails.
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
1. If numeric (int/float):
|
|
38
|
+
- If it's an integer, assume milliseconds
|
|
39
|
+
- If it's a decimal (fractional), assume seconds and convert to ms
|
|
40
|
+
2. If string:
|
|
41
|
+
- If ends with 'ms' (case insensitive), parse as milliseconds
|
|
42
|
+
- If ends with 's' (case insensitive), parse as seconds and convert to ms
|
|
43
|
+
- If no suffix, convert to number and apply rule 1
|
|
44
|
+
'''
|
|
45
|
+
if latency_value is None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
# Handle string input
|
|
50
|
+
if isinstance(latency_value, str):
|
|
51
|
+
value_str = latency_value.strip().lower()
|
|
52
|
+
|
|
53
|
+
# Check for explicit unit suffix
|
|
54
|
+
if value_str.endswith('ms'):
|
|
55
|
+
return round(float(value_str[:-2]), 2)
|
|
56
|
+
elif value_str.endswith('s'):
|
|
57
|
+
return round(float(value_str[:-1]) * 1000, 2)
|
|
58
|
+
else:
|
|
59
|
+
# No suffix, convert to number and apply numeric logic
|
|
60
|
+
num_value = float(value_str)
|
|
61
|
+
if num_value.is_integer():
|
|
62
|
+
# Integer implies milliseconds
|
|
63
|
+
return round(num_value, 2)
|
|
64
|
+
else:
|
|
65
|
+
# Decimal implies seconds, convert to ms
|
|
66
|
+
return round(num_value * 1000, 2)
|
|
67
|
+
|
|
68
|
+
# Handle numeric input
|
|
69
|
+
elif isinstance(latency_value, (int, float)):
|
|
70
|
+
if float(latency_value).is_integer():
|
|
71
|
+
# Integer implies milliseconds
|
|
72
|
+
return round(float(latency_value), 2)
|
|
73
|
+
else:
|
|
74
|
+
# Decimal implies seconds, convert to ms
|
|
75
|
+
return round(float(latency_value) * 1000, 2)
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
except (ValueError, AttributeError):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
23
82
|
class BaseProvider(ABC):
|
|
24
|
-
|
|
83
|
+
'''
|
|
25
84
|
Abstract base class for all search providers.
|
|
26
85
|
Enforces a standard interface for executing search queries.
|
|
27
|
-
|
|
86
|
+
'''
|
|
28
87
|
|
|
29
88
|
@abstractmethod
|
|
30
89
|
def search(self, query, api_key, **kwargs):
|
|
31
|
-
|
|
90
|
+
'''
|
|
32
91
|
Execute a search request against the provider.
|
|
33
92
|
|
|
34
93
|
Args:
|
|
@@ -39,7 +98,8 @@ class BaseProvider(ABC):
|
|
|
39
98
|
Returns:
|
|
40
99
|
dict: A standardized dictionary containing:
|
|
41
100
|
- 'results': List of dicts with 'title', 'url', 'snippet'.
|
|
42
|
-
- 'metrics': Dict with 'latency_ms'
|
|
101
|
+
- 'metrics': Dict with 'latency_ms' (client latency), 'server_latency_ms' (server reported latency),
|
|
102
|
+
and 'size_bytes'.
|
|
43
103
|
- 'error': (Optional) Error message string if occurred.
|
|
44
|
-
|
|
104
|
+
'''
|
|
45
105
|
pass
|
|
@@ -18,41 +18,48 @@
|
|
|
18
18
|
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
19
19
|
# DEALINGS IN THE SOFTWARE.
|
|
20
20
|
|
|
21
|
+
import html
|
|
22
|
+
import logging
|
|
21
23
|
import time
|
|
22
|
-
|
|
24
|
+
|
|
23
25
|
import jmespath
|
|
24
|
-
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
from .base import BaseProvider, parse_server_latency
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
25
32
|
|
|
26
33
|
class GenericProvider(BaseProvider):
|
|
27
|
-
|
|
34
|
+
'''
|
|
28
35
|
A generic search provider driven by YAML configuration.
|
|
29
36
|
It constructs HTTP requests dynamically and maps responses using JMESPath.
|
|
30
|
-
|
|
37
|
+
'''
|
|
31
38
|
|
|
32
39
|
def __init__(self, config):
|
|
33
|
-
|
|
40
|
+
'''
|
|
34
41
|
Initialize the provider with a configuration dictionary.
|
|
35
|
-
|
|
42
|
+
|
|
36
43
|
Args:
|
|
37
44
|
config (dict): Configuration containing url, headers, params, and mapping rules.
|
|
38
|
-
|
|
45
|
+
'''
|
|
39
46
|
self.config = config
|
|
40
47
|
self.session = requests.Session() # Persistent connection session
|
|
41
|
-
self._connection_ready = False
|
|
42
|
-
self._last_url = None
|
|
48
|
+
self._connection_ready = False # Connection ready status
|
|
49
|
+
self._last_url = None # Last used URL tracker
|
|
43
50
|
|
|
44
51
|
def _fill_template(self, template_obj, **kwargs):
|
|
45
|
-
|
|
46
|
-
Recursively replaces placeholders (e.g., {query}) in dictionaries or strings
|
|
52
|
+
'''
|
|
53
|
+
Recursively replaces placeholders (e.g., {query}) in dictionaries or strings
|
|
47
54
|
with values provided in kwargs.
|
|
48
|
-
|
|
55
|
+
|
|
49
56
|
Args:
|
|
50
57
|
template_obj (dict | str): The structure containing placeholders.
|
|
51
58
|
**kwargs: Key-value pairs to inject into the template.
|
|
52
|
-
|
|
59
|
+
|
|
53
60
|
Returns:
|
|
54
61
|
The structure with placeholders replaced by actual values.
|
|
55
|
-
|
|
62
|
+
'''
|
|
56
63
|
if isinstance(template_obj, str):
|
|
57
64
|
# Treat None values as empty strings to prevent "None" appearing in URLs
|
|
58
65
|
safe_kwargs = {k: (v if v is not None else '') for k, v in kwargs.items()}
|
|
@@ -66,7 +73,7 @@ class GenericProvider(BaseProvider):
|
|
|
66
73
|
return template_obj
|
|
67
74
|
|
|
68
75
|
def _ensure_connection(self, url, headers):
|
|
69
|
-
|
|
76
|
+
'''
|
|
70
77
|
Pre-warm HTTPS connection and verify availability.
|
|
71
78
|
Uses lightweight HEAD request to verify connection without fetching response body.
|
|
72
79
|
|
|
@@ -76,7 +83,7 @@ class GenericProvider(BaseProvider):
|
|
|
76
83
|
|
|
77
84
|
Returns:
|
|
78
85
|
bool: Whether connection is ready
|
|
79
|
-
|
|
86
|
+
'''
|
|
80
87
|
# Re-warm if URL changed or connection not ready
|
|
81
88
|
if url != self._last_url or not self._connection_ready:
|
|
82
89
|
try:
|
|
@@ -84,13 +91,24 @@ class GenericProvider(BaseProvider):
|
|
|
84
91
|
self.session.head(url, headers=headers, timeout=5)
|
|
85
92
|
self._connection_ready = True
|
|
86
93
|
self._last_url = url
|
|
87
|
-
|
|
94
|
+
logger.debug('[Connection Pool] Connected to: %s', url)
|
|
88
95
|
except Exception as e:
|
|
89
96
|
self._connection_ready = False
|
|
90
|
-
|
|
97
|
+
logger.warning('[Connection Pool] Connection warm-up failed: %s', e)
|
|
91
98
|
raise
|
|
92
99
|
|
|
93
100
|
def search(self, query, api_key, **kwargs):
|
|
101
|
+
'''
|
|
102
|
+
Perform search using the configured API.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
query: Search query string
|
|
106
|
+
api_key: API key for authentication
|
|
107
|
+
**kwargs: Additional parameters (limit, language, api_url)
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
dict: Search results with 'results' and 'metrics' keys
|
|
111
|
+
'''
|
|
94
112
|
# 1. Extract parameters with defaults
|
|
95
113
|
limit = kwargs.get('limit', '10')
|
|
96
114
|
language = kwargs.get('language', 'en-US')
|
|
@@ -99,13 +117,13 @@ class GenericProvider(BaseProvider):
|
|
|
99
117
|
# 2. Determine configuration
|
|
100
118
|
url = custom_url if custom_url else self.config.get('url')
|
|
101
119
|
method = self.config.get('method', 'GET')
|
|
102
|
-
|
|
120
|
+
|
|
103
121
|
# 3. Prepare context for template injection
|
|
104
122
|
context = {
|
|
105
123
|
'query': query,
|
|
106
124
|
'api_key': api_key,
|
|
107
125
|
'limit': limit,
|
|
108
|
-
'language': language
|
|
126
|
+
'language': language,
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
# 4. construct request components
|
|
@@ -113,24 +131,21 @@ class GenericProvider(BaseProvider):
|
|
|
113
131
|
params = self._fill_template(self.config.get('params', {}), **context)
|
|
114
132
|
json_body = self._fill_template(self.config.get('payload', {}), **context)
|
|
115
133
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
logger.info('[%s] Search: URL=%s | Method=%s',
|
|
135
|
+
self.config.get('name', 'Unknown'), url, method)
|
|
136
|
+
|
|
120
137
|
# Ensure connection is pre-warmed (use HEAD request to verify availability)
|
|
121
138
|
# Pre-warming is not counted in request latency, only verifies connection
|
|
122
139
|
try:
|
|
123
140
|
self._ensure_connection(url, headers)
|
|
124
141
|
except Exception as e:
|
|
125
|
-
|
|
142
|
+
logger.error('Connection Warm-up Error: %s', e)
|
|
126
143
|
return {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
'error': f'Connection failed: {str(e)}',
|
|
145
|
+
'results': [],
|
|
146
|
+
'metrics': {'latency_ms': 0, 'server_latency_ms': None, 'size_bytes': 0},
|
|
130
147
|
}
|
|
131
148
|
|
|
132
|
-
start_time = time.time()
|
|
133
|
-
|
|
134
149
|
try:
|
|
135
150
|
req_args = {'headers': headers, 'timeout': 30}
|
|
136
151
|
if params:
|
|
@@ -139,29 +154,31 @@ class GenericProvider(BaseProvider):
|
|
|
139
154
|
req_args['json'] = json_body
|
|
140
155
|
|
|
141
156
|
# Use Session to send request (connection is reused)
|
|
157
|
+
start_time = time.time()
|
|
142
158
|
if method.upper() == 'GET':
|
|
143
159
|
response = self.session.get(url, **req_args)
|
|
144
160
|
else:
|
|
145
161
|
response = self.session.post(url, **req_args)
|
|
162
|
+
end_time = time.time()
|
|
146
163
|
|
|
147
164
|
response.raise_for_status()
|
|
148
165
|
except Exception as e:
|
|
149
|
-
|
|
166
|
+
logger.error('Request Error: %s', e)
|
|
150
167
|
return {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
168
|
+
'error': str(e),
|
|
169
|
+
'results': [],
|
|
170
|
+
'metrics': {'latency_ms': 0, 'server_latency_ms': None, 'size_bytes': 0},
|
|
154
171
|
}
|
|
155
172
|
|
|
156
|
-
end_time = time.time()
|
|
157
|
-
|
|
158
173
|
# 5. Parse and Normalize Response
|
|
159
174
|
try:
|
|
160
175
|
raw_data = response.json()
|
|
161
176
|
except Exception as e:
|
|
162
|
-
|
|
177
|
+
logger.error('JSON Parse Error: %s', e)
|
|
163
178
|
raw_data = {}
|
|
164
179
|
|
|
180
|
+
logger.debug('Full response: %s', raw_data)
|
|
181
|
+
|
|
165
182
|
mapping = self.config.get('response_mapping', {})
|
|
166
183
|
# Use JMESPath to find the list of results
|
|
167
184
|
root_list = jmespath.search(mapping.get('root_path', '@'), raw_data) or []
|
|
@@ -174,13 +191,23 @@ class GenericProvider(BaseProvider):
|
|
|
174
191
|
# Map specific fields (title, url, etc.) based on config
|
|
175
192
|
for std_key, source_path in field_map.items():
|
|
176
193
|
val = jmespath.search(source_path, item)
|
|
177
|
-
|
|
194
|
+
# Decode HTML entities for site_name
|
|
195
|
+
if std_key == 'site_name' and val:
|
|
196
|
+
val = html.unescape(val)
|
|
197
|
+
entry[std_key] = val if val else ''
|
|
178
198
|
normalized_results.append(entry)
|
|
179
199
|
|
|
200
|
+
# Extract server latency from response if configured
|
|
201
|
+
server_latency_path = mapping.get('server_latency_path')
|
|
202
|
+
server_latency_ms = parse_server_latency(
|
|
203
|
+
jmespath.search(server_latency_path, raw_data),
|
|
204
|
+
) if server_latency_path else None
|
|
205
|
+
|
|
180
206
|
return {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
207
|
+
'results': normalized_results,
|
|
208
|
+
'metrics': {
|
|
209
|
+
'latency_ms': round((end_time - start_time) * 1000, 2),
|
|
210
|
+
'server_latency_ms': server_latency_ms,
|
|
211
|
+
'size_bytes': len(response.content),
|
|
212
|
+
},
|
|
186
213
|
}
|