scitex 2.4.1__py3-none-any.whl → 2.4.3__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.
Files changed (36) hide show
  1. scitex/__version__.py +1 -1
  2. scitex/browser/__init__.py +53 -0
  3. scitex/browser/auth/__init__.py +35 -0
  4. scitex/browser/auth/google.py +381 -0
  5. scitex/browser/collaboration/__init__.py +5 -0
  6. scitex/browser/debugging/__init__.py +56 -0
  7. scitex/browser/debugging/_failure_capture.py +372 -0
  8. scitex/browser/debugging/_sync_session.py +259 -0
  9. scitex/browser/debugging/_test_monitor.py +284 -0
  10. scitex/browser/debugging/_visual_cursor.py +432 -0
  11. scitex/scholar/citation_graph/README.md +117 -0
  12. scitex/scholar/citation_graph/__init__.py +29 -0
  13. scitex/scholar/citation_graph/builder.py +214 -0
  14. scitex/scholar/citation_graph/database.py +246 -0
  15. scitex/scholar/citation_graph/example.py +96 -0
  16. scitex/scholar/citation_graph/models.py +80 -0
  17. scitex/scholar/config/ScholarConfig.py +23 -3
  18. scitex/scholar/config/default.yaml +56 -0
  19. scitex/scholar/core/Paper.py +102 -0
  20. scitex/scholar/core/__init__.py +44 -0
  21. scitex/scholar/core/journal_normalizer.py +524 -0
  22. scitex/scholar/core/oa_cache.py +285 -0
  23. scitex/scholar/core/open_access.py +457 -0
  24. scitex/scholar/metadata_engines/ScholarEngine.py +9 -1
  25. scitex/scholar/metadata_engines/individual/CrossRefLocalEngine.py +82 -21
  26. scitex/scholar/pdf_download/ScholarPDFDownloader.py +137 -0
  27. scitex/scholar/pdf_download/strategies/__init__.py +6 -0
  28. scitex/scholar/pdf_download/strategies/open_access_download.py +186 -0
  29. scitex/scholar/pipelines/ScholarPipelineSearchParallel.py +27 -9
  30. scitex/scholar/pipelines/ScholarPipelineSearchSingle.py +24 -8
  31. scitex/scholar/search_engines/ScholarSearchEngine.py +6 -1
  32. {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/METADATA +1 -1
  33. {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/RECORD +36 -20
  34. {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/WHEEL +0 -0
  35. {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/entry_points.txt +0 -0
  36. {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: 2025-12-08
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/browser/debugging/_failure_capture.py
5
+
6
+ """
7
+ Automatic failure capture utilities for Playwright E2E tests.
8
+
9
+ Features:
10
+ - Console log collection with source file/line tracking
11
+ - Error interception (JS errors, unhandled promise rejections, resource failures)
12
+ - Screenshot capture on test failure
13
+ - Page HTML capture for debugging
14
+ - DevTools-like formatted output
15
+ - Pytest integration via fixtures
16
+
17
+ Based on scitex-cloud's console-interceptor.ts functionality.
18
+
19
+ Usage in conftest.py:
20
+ from scitex.browser.debugging import (
21
+ setup_console_interceptor,
22
+ collect_console_logs,
23
+ save_failure_artifacts,
24
+ create_failure_capture_fixture,
25
+ )
26
+ """
27
+
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING
31
+
32
+ if TYPE_CHECKING:
33
+ from playwright.sync_api import Page
34
+
35
+
36
+ # JavaScript code for advanced console interception
37
+ # Mirrors functionality from scitex-cloud/static/shared/ts/utils/console-interceptor.ts
38
+ CONSOLE_INTERCEPTOR_JS = """
39
+ () => {
40
+ if (window._scitex_console_interceptor_setup) return;
41
+
42
+ // Store for captured logs with full details
43
+ window._scitex_console_logs = [];
44
+ window._scitex_console_history = [];
45
+ const maxHistory = 2000;
46
+
47
+ // Store original console methods
48
+ const originalConsole = {
49
+ log: console.log,
50
+ info: console.info,
51
+ warn: console.warn,
52
+ error: console.error,
53
+ debug: console.debug
54
+ };
55
+
56
+ // Get source file and line number from stack trace
57
+ function getSource() {
58
+ try {
59
+ const stack = new Error().stack;
60
+ if (!stack) return '';
61
+ const lines = stack.split('\\n');
62
+ // Skip Error, getSource, capture, and intercepted console method
63
+ for (let i = 4; i < lines.length; i++) {
64
+ const line = lines[i];
65
+ const match = line.match(/(?:https?:\\/\\/[^\\/]+)?([^\\s]+):(\\d+):(\\d+)/);
66
+ if (match) {
67
+ const [, file, lineNum, col] = match;
68
+ const cleanFile = file.split('/').slice(-2).join('/');
69
+ return `${cleanFile}:${lineNum}:${col}`;
70
+ }
71
+ }
72
+ } catch (e) {}
73
+ return '';
74
+ }
75
+
76
+ // Format message from arguments
77
+ function formatMessage(args) {
78
+ return args.map(arg => {
79
+ if (typeof arg === 'object') {
80
+ try { return JSON.stringify(arg, null, 2); }
81
+ catch { return String(arg); }
82
+ }
83
+ return String(arg);
84
+ }).join(' ');
85
+ }
86
+
87
+ // Capture log entry
88
+ function capture(level, args) {
89
+ const message = formatMessage(args);
90
+ const source = getSource();
91
+ const entry = {
92
+ level,
93
+ message,
94
+ source,
95
+ timestamp: Date.now(),
96
+ url: window.location.href
97
+ };
98
+
99
+ window._scitex_console_history.push(entry);
100
+ if (window._scitex_console_history.length > maxHistory) {
101
+ window._scitex_console_history.shift();
102
+ }
103
+
104
+ // Also store simple format for backwards compatibility
105
+ window._scitex_console_logs.push(`[${level.toUpperCase()}] ${source ? source + ' ' : ''}${message}`);
106
+ if (window._scitex_console_logs.length > 500) {
107
+ window._scitex_console_logs.shift();
108
+ }
109
+ }
110
+
111
+ // Intercept console methods
112
+ ['log', 'info', 'warn', 'error', 'debug'].forEach(level => {
113
+ console[level] = function(...args) {
114
+ originalConsole[level].apply(console, args);
115
+ capture(level, args);
116
+ };
117
+ });
118
+
119
+ // Capture unhandled JavaScript errors
120
+ window.addEventListener('error', (event) => {
121
+ let entry;
122
+ if (event.target && event.target.tagName) {
123
+ // Resource loading error
124
+ const target = event.target;
125
+ const src = target.src || target.href || '';
126
+ if (src) {
127
+ entry = {
128
+ level: 'error',
129
+ message: `Failed to load resource: ${src}`,
130
+ source: src.split('/').pop() || '',
131
+ timestamp: Date.now(),
132
+ url: window.location.href
133
+ };
134
+ }
135
+ } else {
136
+ // JavaScript error
137
+ entry = {
138
+ level: 'error',
139
+ message: event.message,
140
+ source: `${event.filename}:${event.lineno}:${event.colno}`,
141
+ timestamp: Date.now(),
142
+ url: window.location.href
143
+ };
144
+ }
145
+ if (entry) {
146
+ window._scitex_console_history.push(entry);
147
+ window._scitex_console_logs.push(`[ERROR] ${entry.source} ${entry.message}`);
148
+ }
149
+ }, true);
150
+
151
+ // Capture unhandled promise rejections
152
+ window.addEventListener('unhandledrejection', (event) => {
153
+ const entry = {
154
+ level: 'error',
155
+ message: `Uncaught (in promise): ${event.reason}`,
156
+ source: '',
157
+ timestamp: Date.now(),
158
+ url: window.location.href
159
+ };
160
+ window._scitex_console_history.push(entry);
161
+ window._scitex_console_logs.push(`[ERROR] Uncaught (in promise): ${event.reason}`);
162
+ });
163
+
164
+ window._scitex_console_interceptor_setup = true;
165
+ }
166
+ """
167
+
168
+
169
+ def setup_console_interceptor(page: "Page") -> None:
170
+ """Set up console log interceptor with source tracking and error capture.
171
+
172
+ Features (mirroring console-interceptor.ts):
173
+ - Intercepts console.log, info, warn, error, debug
174
+ - Captures source file and line number
175
+ - Captures unhandled JS errors
176
+ - Captures unhandled promise rejections
177
+ - Captures resource loading failures
178
+
179
+ Call this at the start of each test to begin capturing logs.
180
+ """
181
+ try:
182
+ page.evaluate(CONSOLE_INTERCEPTOR_JS)
183
+ except Exception:
184
+ pass
185
+
186
+
187
+ def collect_console_logs(page: "Page") -> list:
188
+ """Collect all captured console logs from the browser.
189
+
190
+ Returns:
191
+ List of log strings in format "[LEVEL] source message"
192
+ """
193
+ try:
194
+ logs = page.evaluate("""
195
+ () => {
196
+ if (window._scitex_console_logs) {
197
+ return window._scitex_console_logs;
198
+ }
199
+ return [];
200
+ }
201
+ """)
202
+ return logs or []
203
+ except Exception:
204
+ return []
205
+
206
+
207
+ def collect_console_logs_detailed(page: "Page") -> list:
208
+ """Collect all captured console logs with full details.
209
+
210
+ Returns:
211
+ List of dicts with keys: level, message, source, timestamp, url
212
+ """
213
+ try:
214
+ history = page.evaluate("""
215
+ () => {
216
+ if (window._scitex_console_history) {
217
+ return window._scitex_console_history;
218
+ }
219
+ return [];
220
+ }
221
+ """)
222
+ return history or []
223
+ except Exception:
224
+ return []
225
+
226
+
227
+ def format_logs_devtools_style(logs: list) -> str:
228
+ """Format logs in DevTools-like style.
229
+
230
+ Args:
231
+ logs: List of detailed log entries from collect_console_logs_detailed()
232
+
233
+ Returns:
234
+ Formatted string like browser DevTools output
235
+ """
236
+ if not logs:
237
+ return "No console logs captured."
238
+
239
+ level_icons = {
240
+ "error": "[ERROR]",
241
+ "warn": "[WARN]",
242
+ "info": "[INFO]",
243
+ "debug": "[DEBUG]",
244
+ "log": "[LOG]",
245
+ }
246
+
247
+ output = []
248
+ for entry in logs:
249
+ if isinstance(entry, dict):
250
+ level = entry.get("level", "log")
251
+ source = entry.get("source", "")
252
+ message = entry.get("message", "")
253
+ icon = level_icons.get(level, "[LOG]")
254
+ source_str = f" {source}" if source else ""
255
+ output.append(f"{icon}{source_str} {message}")
256
+ else:
257
+ output.append(str(entry))
258
+
259
+ return "\n".join(output)
260
+
261
+
262
+ def save_failure_artifacts(
263
+ page: "Page",
264
+ test_name: str,
265
+ artifacts_dir: Path | str,
266
+ console_logs: list | None = None,
267
+ ) -> dict:
268
+ """Save screenshot, console logs, and page HTML on test failure.
269
+
270
+ Args:
271
+ page: Playwright page object
272
+ test_name: Name of the failed test (e.g., request.node.nodeid)
273
+ artifacts_dir: Directory to save artifacts
274
+ console_logs: Pre-collected console logs (optional, will collect if None)
275
+
276
+ Returns:
277
+ Dict with paths to saved artifacts
278
+ """
279
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
280
+ safe_test_name = test_name.replace("::", "_").replace("[", "_").replace("]", "").replace("/", "_")
281
+
282
+ # Create artifacts directory with timestamp
283
+ artifacts_path = Path(artifacts_dir) / timestamp
284
+ artifacts_path.mkdir(parents=True, exist_ok=True)
285
+
286
+ saved_files = {}
287
+
288
+ # Collect console logs if not provided
289
+ if console_logs is None:
290
+ console_logs = collect_console_logs(page)
291
+
292
+ # Save screenshot
293
+ try:
294
+ screenshot_path = artifacts_path / f"{safe_test_name}_screenshot.png"
295
+ page.screenshot(path=str(screenshot_path), full_page=True)
296
+ saved_files["screenshot"] = screenshot_path
297
+ print(f"\n[FAILURE] Screenshot saved: {screenshot_path}")
298
+ except Exception as e:
299
+ print(f"\n[FAILURE] Failed to save screenshot: {e}")
300
+
301
+ # Save console logs
302
+ try:
303
+ logs_path = artifacts_path / f"{safe_test_name}_console.log"
304
+ with open(logs_path, "w") as f:
305
+ f.write(f"Test: {test_name}\n")
306
+ f.write(f"Timestamp: {timestamp}\n")
307
+ f.write(f"URL: {page.url}\n")
308
+ f.write("=" * 80 + "\n\n")
309
+ f.write("Console Logs:\n")
310
+ f.write("-" * 40 + "\n")
311
+ for log in console_logs:
312
+ f.write(f"{log}\n")
313
+ saved_files["console_logs"] = logs_path
314
+ print(f"[FAILURE] Console logs saved: {logs_path}")
315
+ except Exception as e:
316
+ print(f"[FAILURE] Failed to save console logs: {e}")
317
+
318
+ # Save page HTML
319
+ try:
320
+ html_path = artifacts_path / f"{safe_test_name}_page.html"
321
+ html_content = page.content()
322
+ with open(html_path, "w") as f:
323
+ f.write(html_content)
324
+ saved_files["page_html"] = html_path
325
+ print(f"[FAILURE] Page HTML saved: {html_path}")
326
+ except Exception as e:
327
+ print(f"[FAILURE] Failed to save page HTML: {e}")
328
+
329
+ return saved_files
330
+
331
+
332
+ def create_failure_capture_fixture(artifacts_dir: Path | str):
333
+ """Create a pytest fixture for automatic failure capture.
334
+
335
+ Usage in conftest.py:
336
+ from scitex.browser.debugging import create_failure_capture_fixture
337
+
338
+ capture_on_failure = create_failure_capture_fixture(
339
+ Path(__file__).parent / "artifacts"
340
+ )
341
+
342
+ Args:
343
+ artifacts_dir: Directory to save failure artifacts
344
+
345
+ Returns:
346
+ A pytest fixture function
347
+ """
348
+ import pytest
349
+
350
+ @pytest.fixture(autouse=True)
351
+ def capture_on_failure(request, page):
352
+ """Automatically capture console logs and screenshot on test failure."""
353
+ setup_console_interceptor(page)
354
+ yield
355
+ if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
356
+ console_logs = collect_console_logs(page)
357
+ save_failure_artifacts(page, request.node.nodeid, artifacts_dir, console_logs)
358
+
359
+ return capture_on_failure
360
+
361
+
362
+ # Pytest hook for capturing test results - add to conftest.py
363
+ PYTEST_HOOK_CODE = '''
364
+ @pytest.hookimpl(tryfirst=True, hookwrapper=True)
365
+ def pytest_runtest_makereport(item, call):
366
+ """Hook to capture test outcome for use in fixture."""
367
+ outcome = yield
368
+ rep = outcome.get_result()
369
+ setattr(item, f"rep_{rep.when}", rep)
370
+ '''
371
+
372
+ # EOF
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: 2025-12-08
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/browser/debugging/_sync_session.py
5
+
6
+ """
7
+ Sync browser session context manager for pytest-playwright E2E tests.
8
+
9
+ Ensures proper cleanup of browser processes to prevent zombies.
10
+
11
+ Usage in conftest.py:
12
+ from scitex.browser import SyncBrowserSession
13
+
14
+ @pytest.fixture
15
+ def browser_session(page: Page):
16
+ with SyncBrowserSession(page) as session:
17
+ yield session
18
+ # Cleanup happens automatically even on exceptions
19
+
20
+ Or use the fixture factory:
21
+ from scitex.browser import create_browser_session_fixture
22
+ browser_session = create_browser_session_fixture()
23
+ """
24
+
25
+ import atexit
26
+ import os
27
+ import signal
28
+ import subprocess
29
+ from contextlib import contextmanager
30
+ from typing import TYPE_CHECKING, Callable, Optional
31
+
32
+ if TYPE_CHECKING:
33
+ from playwright.sync_api import Page
34
+
35
+
36
+ class SyncBrowserSession:
37
+ """
38
+ Sync context manager for playwright browser sessions.
39
+
40
+ Ensures zombie process cleanup on test failures, timeouts, or crashes.
41
+ Tracks browser PIDs and kills orphaned processes on exit.
42
+ """
43
+
44
+ # Class-level tracking of active sessions for emergency cleanup
45
+ _active_sessions: list["SyncBrowserSession"] = []
46
+ _cleanup_registered = False
47
+
48
+ def __init__(
49
+ self,
50
+ page: "Page",
51
+ timeout: int = 60,
52
+ on_enter: Optional[Callable[["Page"], None]] = None,
53
+ on_exit: Optional[Callable[["Page", bool], None]] = None,
54
+ ):
55
+ """
56
+ Initialize sync browser session.
57
+
58
+ Args:
59
+ page: Playwright page instance from pytest-playwright
60
+ timeout: Default timeout for operations in seconds
61
+ on_enter: Callback when entering context
62
+ on_exit: Callback when exiting context (receives page and success flag)
63
+ """
64
+ self.page = page
65
+ self.timeout = timeout
66
+ self.on_enter = on_enter
67
+ self.on_exit = on_exit
68
+ self._browser_pid = None
69
+ self._context_pid = None
70
+ self._success = True
71
+
72
+ # Register class-level emergency cleanup
73
+ if not SyncBrowserSession._cleanup_registered:
74
+ atexit.register(SyncBrowserSession._emergency_cleanup)
75
+ SyncBrowserSession._cleanup_registered = True
76
+
77
+ def __enter__(self) -> "SyncBrowserSession":
78
+ """Enter context - track browser PIDs and run setup callback."""
79
+ # Track this session
80
+ SyncBrowserSession._active_sessions.append(self)
81
+
82
+ # Try to get browser PID for tracking
83
+ try:
84
+ if self.page.context.browser:
85
+ # Get the browser process
86
+ browser = self.page.context.browser
87
+ # Browser PID is available via internal _impl
88
+ if hasattr(browser, '_impl'):
89
+ impl = browser._impl
90
+ if hasattr(impl, '_process'):
91
+ self._browser_pid = impl._process.pid
92
+ except Exception:
93
+ pass # PID tracking is best-effort
94
+
95
+ # Run setup callback
96
+ if self.on_enter:
97
+ self.on_enter(self.page)
98
+
99
+ return self
100
+
101
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
102
+ """Exit context - ensure cleanup happens."""
103
+ self._success = exc_type is None
104
+
105
+ # Remove from active sessions
106
+ try:
107
+ SyncBrowserSession._active_sessions.remove(self)
108
+ except ValueError:
109
+ pass
110
+
111
+ # Run exit callback
112
+ if self.on_exit:
113
+ try:
114
+ self.on_exit(self.page, self._success)
115
+ except Exception:
116
+ pass # Don't fail on callback errors
117
+
118
+ # If there was an exception, try to close gracefully
119
+ if exc_type is not None:
120
+ try:
121
+ self.page.close()
122
+ except Exception:
123
+ pass
124
+
125
+ try:
126
+ self.page.context.close()
127
+ except Exception:
128
+ pass
129
+
130
+ # Kill orphaned browser process if we have the PID
131
+ if self._browser_pid and not self._success:
132
+ self._kill_process_tree(self._browser_pid)
133
+
134
+ # Don't suppress the exception
135
+ return False
136
+
137
+ @staticmethod
138
+ def _kill_process_tree(pid: int):
139
+ """Kill a process and all its children (zombies)."""
140
+ try:
141
+ # Try SIGTERM first
142
+ os.kill(pid, signal.SIGTERM)
143
+ except ProcessLookupError:
144
+ return # Already dead
145
+ except PermissionError:
146
+ return # Can't kill
147
+
148
+ # Give it a moment
149
+ import time
150
+ time.sleep(0.5)
151
+
152
+ # Force kill if still running
153
+ try:
154
+ os.kill(pid, signal.SIGKILL)
155
+ except (ProcessLookupError, PermissionError):
156
+ pass
157
+
158
+ @classmethod
159
+ def _emergency_cleanup(cls):
160
+ """Emergency cleanup of all active sessions on process exit."""
161
+ for session in cls._active_sessions[:]: # Copy list to avoid mutation
162
+ if session._browser_pid:
163
+ cls._kill_process_tree(session._browser_pid)
164
+ cls._active_sessions.clear()
165
+
166
+ @staticmethod
167
+ def kill_zombie_browsers():
168
+ """Kill all zombie chromium/chrome processes from failed tests.
169
+
170
+ Call this at the start of test sessions to clean up from previous runs.
171
+ """
172
+ try:
173
+ # Find orphaned chromium processes
174
+ result = subprocess.run(
175
+ ["pgrep", "-f", "chromium|chrome"],
176
+ capture_output=True,
177
+ text=True,
178
+ )
179
+ if result.returncode == 0:
180
+ pids = result.stdout.strip().split('\n')
181
+ for pid in pids:
182
+ if pid:
183
+ try:
184
+ os.kill(int(pid), signal.SIGKILL)
185
+ except (ProcessLookupError, PermissionError, ValueError):
186
+ pass
187
+ except FileNotFoundError:
188
+ pass # pgrep not available
189
+
190
+
191
+ @contextmanager
192
+ def sync_browser_session(
193
+ page: "Page",
194
+ timeout: int = 60,
195
+ on_enter: Optional[Callable[["Page"], None]] = None,
196
+ on_exit: Optional[Callable[["Page", bool], None]] = None,
197
+ ):
198
+ """
199
+ Context manager for sync playwright sessions.
200
+
201
+ Usage:
202
+ with sync_browser_session(page) as session:
203
+ session.page.goto(url)
204
+ # ... test code
205
+ # Cleanup happens automatically
206
+ """
207
+ session = SyncBrowserSession(page, timeout, on_enter, on_exit)
208
+ with session:
209
+ yield session
210
+
211
+
212
+ def create_browser_session_fixture(
213
+ timeout: int = 60,
214
+ setup: Optional[Callable[["Page"], None]] = None,
215
+ teardown: Optional[Callable[["Page", bool], None]] = None,
216
+ kill_zombies_on_start: bool = True,
217
+ ):
218
+ """
219
+ Create a pytest fixture for browser session with cleanup.
220
+
221
+ Usage in conftest.py:
222
+ from scitex.browser import create_browser_session_fixture
223
+
224
+ browser_session = create_browser_session_fixture(
225
+ timeout=60,
226
+ setup=lambda page: print(f"Starting test"),
227
+ teardown=lambda page, success: print(f"Test {'passed' if success else 'failed'}"),
228
+ kill_zombies_on_start=True,
229
+ )
230
+
231
+ Args:
232
+ timeout: Default timeout for operations
233
+ setup: Callback when entering session
234
+ teardown: Callback when exiting (receives page and success flag)
235
+ kill_zombies_on_start: Kill orphaned browsers before first test
236
+
237
+ Returns:
238
+ A pytest fixture function
239
+ """
240
+ import pytest
241
+
242
+ _zombies_cleaned = False
243
+
244
+ @pytest.fixture
245
+ def browser_session(page: "Page"):
246
+ nonlocal _zombies_cleaned
247
+
248
+ # Clean up zombies from previous runs (once per session)
249
+ if kill_zombies_on_start and not _zombies_cleaned:
250
+ SyncBrowserSession.kill_zombie_browsers()
251
+ _zombies_cleaned = True
252
+
253
+ with SyncBrowserSession(page, timeout, setup, teardown) as session:
254
+ yield session
255
+
256
+ return browser_session
257
+
258
+
259
+ # EOF