search-api-webui 0.1.7__py3-none-any.whl → 0.1.9__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.
@@ -17,4 +17,3 @@
17
17
  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18
18
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
19
  # DEALINGS IN THE SOFTWARE.
20
-
search_api_webui/app.py CHANGED
@@ -19,25 +19,64 @@
19
19
  # DEALINGS IN THE SOFTWARE.
20
20
 
21
21
  import json
22
- import os
22
+ import logging
23
+ import socket
24
+ import sys
25
+ import threading
26
+ import time
23
27
  import webbrowser
24
28
  from pathlib import Path
25
- from flask import Flask, request, jsonify, send_from_directory
29
+
30
+ from flask import Flask, jsonify, request, send_from_directory
26
31
  from flask_cors import CORS
32
+
27
33
  from search_api_webui.providers import load_providers
28
34
 
29
- CURRENT_DIR = Path(__file__).resolve().parent
35
+ try:
36
+ import webview
37
+ WEBVIEW_AVAILABLE = True
38
+ except ImportError:
39
+ WEBVIEW_AVAILABLE = False
40
+
41
+
42
+ # Configure logging
43
+ logging.basicConfig(
44
+ level=logging.INFO,
45
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
46
+ )
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ def get_resource_path(relative_path):
51
+ '''Get absolute path to resource, works for dev and for PyInstaller.'''
52
+ try:
53
+ # PyInstaller creates a temp folder and stores path in _MEIPASS
54
+ base_path = Path(sys._MEIPASS)
55
+ except Exception:
56
+ base_path = Path(__file__).resolve().parent
57
+
58
+ return base_path / relative_path
30
59
 
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
60
 
37
- app = Flask(__name__, static_folder='static')
61
+ CURRENT_DIR = Path(__file__).resolve().parent
62
+
63
+ # Handle static folder for both dev and packaged app
64
+ if hasattr(sys, '_MEIPASS'):
65
+ # Running in PyInstaller bundle
66
+ STATIC_FOLDER = Path(sys._MEIPASS) / 'static'
67
+ else:
68
+ # Running in development
69
+ STATIC_FOLDER = CURRENT_DIR / 'static'
70
+ if not STATIC_FOLDER.exists():
71
+ DEV_FRONTEND_DIST = CURRENT_DIR.parent / 'frontend' / 'dist'
72
+ if DEV_FRONTEND_DIST.exists():
73
+ STATIC_FOLDER = DEV_FRONTEND_DIST
74
+
75
+ app = Flask(__name__, static_folder=str(STATIC_FOLDER))
38
76
  CORS(app)
39
77
 
40
- PROVIDERS_YAML = CURRENT_DIR / 'providers.yaml'
78
+ # Use get_resource_path for providers.yaml
79
+ PROVIDERS_YAML = get_resource_path('providers.yaml')
41
80
  USER_CONFIG_DIR = Path.home() / '.search-api-webui'
42
81
  USER_CONFIG_JSON = USER_CONFIG_DIR / 'config.json'
43
82
 
@@ -47,25 +86,28 @@ if not USER_CONFIG_DIR.exists():
47
86
  if PROVIDERS_YAML.exists():
48
87
  provider_map = load_providers(str(PROVIDERS_YAML))
49
88
  else:
50
- print(f"Error: Configuration file not found at {PROVIDERS_YAML}")
89
+ logger.error(f'Configuration file not found at {PROVIDERS_YAML}')
51
90
  provider_map = {}
52
91
 
92
+
53
93
  def get_stored_config():
54
94
  if not USER_CONFIG_JSON.exists():
55
95
  return {}
56
96
  try:
57
- with open(USER_CONFIG_JSON, 'r', encoding='utf-8') as f:
97
+ with open(USER_CONFIG_JSON, encoding='utf-8') as f:
58
98
  return json.load(f)
59
99
  except Exception as e:
60
- print(f"Error reading config: {e}")
100
+ logger.error(f'Error reading config: {e}')
61
101
  return {}
62
102
 
103
+
63
104
  def save_stored_config(config_dict):
64
105
  try:
65
106
  with open(USER_CONFIG_JSON, 'w', encoding='utf-8') as f:
66
107
  json.dump(config_dict, f, indent=2)
67
108
  except Exception as e:
68
- print(f"Error saving config: {e}")
109
+ logger.error(f'Error saving config: {e}')
110
+
69
111
 
70
112
  @app.route('/api/providers', methods=['GET'])
71
113
  def get_providers_list():
@@ -76,60 +118,77 @@ def get_providers_list():
76
118
  config_details = provider_instance.config
77
119
 
78
120
  user_conf = stored_config.get(name, {})
79
-
121
+
80
122
  if isinstance(user_conf, str):
81
123
  user_conf = {'api_key': user_conf}
82
124
 
83
125
  has_key = bool(user_conf.get('api_key'))
84
126
 
85
- providers_info.append({
86
- "name": name,
87
- "has_key": has_key,
88
- "details": config_details,
89
- "user_settings": {
90
- "api_url": user_conf.get('api_url', ''),
91
- "limit": user_conf.get('limit', '10'),
92
- "language": user_conf.get('language', 'en-US')
93
- }
94
- })
127
+ providers_info.append(
128
+ {
129
+ 'name': name,
130
+ 'has_key': has_key,
131
+ 'details': config_details,
132
+ 'user_settings': {
133
+ 'api_url': user_conf.get('api_url', ''),
134
+ 'limit': user_conf.get('limit', '10'),
135
+ 'language': user_conf.get('language'),
136
+ },
137
+ },
138
+ )
95
139
  return jsonify(providers_info)
96
140
 
141
+
97
142
  @app.route('/api/config', methods=['POST'])
98
143
  def update_config():
99
144
  data = request.json
100
145
  provider_name = data.get('provider')
101
146
 
102
147
  if not provider_name:
103
- return jsonify({"error": "Provider name is required"}), 400
104
-
105
- if 'api_key' not in data:
106
- return jsonify({"error": "API Key field is missing"}), 400
148
+ return jsonify({'error': 'Provider name is required'}), 400
107
149
 
108
150
  api_key = data.get('api_key')
109
151
 
110
152
  api_url = data.get('api_url', '').strip()
111
153
  limit = data.get('limit', '10')
112
- language = data.get('language', 'en-US')
154
+ language = data.get('language')
113
155
 
114
156
  all_config = get_stored_config()
115
157
 
116
158
  if provider_name in all_config and isinstance(all_config[provider_name], str):
117
159
  all_config[provider_name] = {'api_key': all_config[provider_name]}
118
160
 
119
- if not api_key:
120
- if provider_name in all_config:
121
- all_config[provider_name]['api_key'] = ""
122
- else:
123
- if provider_name not in all_config:
124
- all_config[provider_name] = {}
161
+ # Initialize provider config if not exists
162
+ if provider_name not in all_config:
163
+ all_config[provider_name] = {}
125
164
 
126
- all_config[provider_name]['api_key'] = api_key
165
+ # Update advanced settings, skip empty values
166
+ if api_url:
127
167
  all_config[provider_name]['api_url'] = api_url
168
+ elif 'api_url' in all_config[provider_name]:
169
+ del all_config[provider_name]['api_url']
170
+
171
+ if limit:
128
172
  all_config[provider_name]['limit'] = limit
173
+ elif 'limit' in all_config[provider_name]:
174
+ del all_config[provider_name]['limit']
175
+
176
+ if language:
129
177
  all_config[provider_name]['language'] = language
178
+ elif 'language' in all_config[provider_name]:
179
+ del all_config[provider_name]['language']
180
+
181
+ # Only update api_key if explicitly provided
182
+ if api_key is not None:
183
+ all_config[provider_name]['api_key'] = api_key
184
+
185
+ # Clean up empty provider config
186
+ if not all_config[provider_name]:
187
+ del all_config[provider_name]
130
188
 
131
189
  save_stored_config(all_config)
132
- return jsonify({"status": "success"})
190
+ return jsonify({'status': 'success'})
191
+
133
192
 
134
193
  @app.route('/api/search', methods=['POST'])
135
194
  def search_api():
@@ -149,46 +208,104 @@ def search_api():
149
208
  api_key = provider_config.get('api_key')
150
209
 
151
210
  if not api_key:
152
- return jsonify({"error": f"API Key for {provider_name} is missing. Please configure it."}), 401
211
+ return (
212
+ jsonify({'error': f'API Key for {provider_name} is missing. Please configure it.'}),
213
+ 401,
214
+ )
153
215
 
154
216
  provider = provider_map.get(provider_name)
155
217
  if not provider:
156
- return jsonify({"error": "Provider not found"}), 404
218
+ return jsonify({'error': 'Provider not found'}), 404
157
219
 
158
220
  search_kwargs = {
159
221
  'api_url': provider_config.get('api_url'),
160
222
  'limit': provider_config.get('limit'),
161
- 'language': provider_config.get('language')
223
+ 'language': provider_config.get('language'),
162
224
  }
163
225
 
164
226
  result = provider.search(query, api_key, **search_kwargs)
165
227
  return jsonify(result)
166
228
 
229
+
167
230
  # Host React Frontend
168
231
  @app.route('/', defaults={'path': ''})
169
232
  @app.route('/<path:path>')
170
233
  def serve(path):
171
- if path != "" and (STATIC_FOLDER / path).exists():
234
+ if path != '' and (STATIC_FOLDER / path).exists():
172
235
  return send_from_directory(str(STATIC_FOLDER), path)
173
236
  else:
174
237
  return send_from_directory(str(STATIC_FOLDER), 'index.html')
175
238
 
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
239
 
183
- url = f"http://{args.host}:{args.port}"
184
- print(f"Starting Search API WebUI...")
185
- print(f" - Config Storage: {USER_CONFIG_JSON}")
186
- print(f" - Serving on: {url}")
240
+ def wait_for_server_ready(host, port):
241
+ start_time = time.time()
242
+ while time.time() - start_time < 10:
243
+ try:
244
+ with socket.create_connection((host, port), timeout=1):
245
+ return True
246
+ except (OSError, ConnectionRefusedError):
247
+ time.sleep(0.1)
248
+ return False
187
249
 
188
- # Open browser automatically after a short delay to ensure server is ready
189
- webbrowser.open(url)
190
250
 
191
- app.run(host=args.host, port=args.port)
251
+ def main():
252
+ import argparse
253
+
254
+ parser = argparse.ArgumentParser(description='Search API WebUI')
255
+ parser.add_argument('--port', type=int, default=8889, help='Port to run the server on')
256
+ parser.add_argument('--host', type=str, default='localhost', help='Host to run the server on')
257
+ parser.add_argument('-w', '--webview', action='store_true', help='Use webview to open the application')
258
+ args = parser.parse_args()
192
259
 
193
- if __name__ == "__main__":
260
+ url = f'http://{args.host}:{args.port}'
261
+ logger.info('Starting Search API WebUI...')
262
+ logger.info(f' - Config Storage: {USER_CONFIG_JSON}')
263
+ logger.info(f' - Serving on: {url}')
264
+ if args.webview:
265
+ logger.info(' - Mode: webview')
266
+
267
+ if args.webview:
268
+ if not WEBVIEW_AVAILABLE:
269
+ logger.warning('webview library not installed. Falling back to webbrowser.')
270
+ # Start server in background thread and wait for it to be ready
271
+ server_thread = threading.Thread(
272
+ target=lambda: app.run(
273
+ host=args.host, port=args.port, use_reloader=False,
274
+ ),
275
+ daemon=True,
276
+ )
277
+ server_thread.start()
278
+ if wait_for_server_ready(args.host, args.port):
279
+ logger.info(f'Server is ready! Opening browser: {url}')
280
+ webbrowser.open(url)
281
+ else:
282
+ logger.error('Server took too long to start. Browser not opened.')
283
+ else:
284
+ # Start server in background thread and wait for it to be ready, then start webview
285
+ server_thread = threading.Thread(
286
+ target=lambda: app.run(
287
+ host=args.host, port=args.port, use_reloader=False,
288
+ ),
289
+ daemon=True,
290
+ )
291
+ server_thread.start()
292
+ if wait_for_server_ready(args.host, args.port):
293
+ logger.info('Server is ready! Using webview mode...')
294
+ webview.create_window('Search API WebUI', url, width=1200, height=800)
295
+ webview.start()
296
+ else:
297
+ logger.error('Server took too long to start. Webview not opened.')
298
+ else:
299
+ # Start a background thread to check server status and open the browser automatically
300
+ def open_browser():
301
+ if wait_for_server_ready(args.host, args.port):
302
+ logger.info(f'Server is ready! Opening browser: {url}')
303
+ webbrowser.open(url)
304
+ else:
305
+ logger.error('Server took too long to start. Browser not opened.')
306
+ threading.Thread(target=open_browser, daemon=True).start()
307
+ app.run(host=args.host, port=args.port)
308
+
309
+
310
+ if __name__ == '__main__':
194
311
  main()
@@ -18,26 +18,32 @@
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 logging
21
22
  import os
23
+
22
24
  import yaml
25
+
23
26
  from .generic import GenericProvider
24
27
  from .querit import QueritSdkProvider
25
28
 
29
+ logger = logging.getLogger(__name__)
30
+
31
+
26
32
  def load_providers(file_path='providers.yaml'):
27
- """
33
+ '''
28
34
  Parses the YAML configuration file and instantiates the appropriate provider classes.
29
-
35
+
30
36
  Args:
31
37
  file_path (str): Path to the providers configuration file.
32
-
38
+
33
39
  Returns:
34
40
  dict: A dictionary mapping provider names to their initialized instances.
35
- """
41
+ '''
36
42
  if not os.path.exists(file_path):
37
- print(f"Warning: Provider config file not found at {file_path}")
43
+ logger.warning(f'Provider config file not found at {file_path}')
38
44
  return {}
39
-
40
- with open(file_path, 'r', encoding='utf-8') as f:
45
+
46
+ with open(file_path, encoding='utf-8') as f:
41
47
  configs = yaml.safe_load(f)
42
48
 
43
49
  providers = {}
@@ -19,16 +19,108 @@
19
19
  # DEALINGS IN THE SOFTWARE.
20
20
 
21
21
  from abc import ABC, abstractmethod
22
+ from urllib.parse import urlparse
23
+
24
+
25
+ def extract_domain_from_url(url):
26
+ '''
27
+ Extract domain name from URL, removing 'www.' prefix.
28
+
29
+ Args:
30
+ url (str): The URL to extract domain from.
31
+
32
+ Returns:
33
+ str | None: The domain name without 'www.' prefix
34
+ (e.g., 'example.com' from 'https://www.example.com/path'),
35
+ or None if the URL is invalid or has no netloc.
36
+
37
+ Examples:
38
+ >>> extract_domain_from_url('https://www.example.com/path')
39
+ 'example.com'
40
+ >>> extract_domain_from_url('http://example.com')
41
+ 'example.com'
42
+ >>> extract_domain_from_url('invalid-url')
43
+ None
44
+ '''
45
+ if not url or not isinstance(url, str):
46
+ return None
47
+ try:
48
+ parsed = urlparse(url)
49
+ domain = parsed.netloc if parsed.netloc else None
50
+ if domain and domain.startswith('www.'):
51
+ domain = domain[4:]
52
+ return domain
53
+ except Exception:
54
+ return None
55
+
56
+
57
+ def parse_server_latency(latency_value):
58
+ '''
59
+ Parse server latency value from various formats to milliseconds.
60
+
61
+ Args:
62
+ latency_value: The latency value to parse. Can be:
63
+ - int/float: A numeric value
64
+ - str: A string with or without unit suffix (e.g., "123ms", "0.123s", "123")
65
+
66
+ Returns:
67
+ float | None: The latency in milliseconds, or None if parsing fails.
68
+
69
+ Rules:
70
+ 1. If numeric (int/float):
71
+ - If it's an integer, assume milliseconds
72
+ - If it's a decimal (fractional), assume seconds and convert to ms
73
+ 2. If string:
74
+ - If ends with 'ms' (case insensitive), parse as milliseconds
75
+ - If ends with 's' (case insensitive), parse as seconds and convert to ms
76
+ - If no suffix, convert to number and apply rule 1
77
+ '''
78
+ if latency_value is None:
79
+ return None
80
+
81
+ try:
82
+ # Handle string input
83
+ if isinstance(latency_value, str):
84
+ value_str = latency_value.strip().lower()
85
+
86
+ # Check for explicit unit suffix
87
+ if value_str.endswith('ms'):
88
+ return round(float(value_str[:-2]), 2)
89
+ elif value_str.endswith('s'):
90
+ return round(float(value_str[:-1]) * 1000, 2)
91
+ else:
92
+ # No suffix, convert to number and apply numeric logic
93
+ num_value = float(value_str)
94
+ if num_value.is_integer():
95
+ # Integer implies milliseconds
96
+ return round(num_value, 2)
97
+ else:
98
+ # Decimal implies seconds, convert to ms
99
+ return round(num_value * 1000, 2)
100
+
101
+ # Handle numeric input
102
+ elif isinstance(latency_value, (int, float)):
103
+ if float(latency_value).is_integer():
104
+ # Integer implies milliseconds
105
+ return round(float(latency_value), 2)
106
+ else:
107
+ # Decimal implies seconds, convert to ms
108
+ return round(float(latency_value) * 1000, 2)
109
+
110
+ return None
111
+ except (ValueError, AttributeError):
112
+ return None
113
+
22
114
 
23
115
  class BaseProvider(ABC):
24
- """
116
+ '''
25
117
  Abstract base class for all search providers.
26
118
  Enforces a standard interface for executing search queries.
27
- """
119
+ '''
28
120
 
29
121
  @abstractmethod
30
122
  def search(self, query, api_key, **kwargs):
31
- """
123
+ '''
32
124
  Execute a search request against the provider.
33
125
 
34
126
  Args:
@@ -39,7 +131,8 @@ class BaseProvider(ABC):
39
131
  Returns:
40
132
  dict: A standardized dictionary containing:
41
133
  - 'results': List of dicts with 'title', 'url', 'snippet'.
42
- - 'metrics': Dict with 'latency_ms' and 'size_bytes'.
134
+ - 'metrics': Dict with 'latency_ms' (client latency), 'server_latency_ms' (server reported latency),
135
+ and 'size_bytes'.
43
136
  - 'error': (Optional) Error message string if occurred.
44
- """
137
+ '''
45
138
  pass