clash-linux commited on
Commit
b9ebd37
·
verified ·
1 Parent(s): 919151d

Upload 9 files

Browse files
Files changed (1) hide show
  1. main.py +80 -164
main.py CHANGED
@@ -34,7 +34,17 @@ if sys.platform == "win32":
34
  logging.info("Set WindowsProactorEventLoopPolicy for asyncio.")
35
 
36
  # --- Logging Configuration ---
37
- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
 
 
 
 
 
 
 
 
 
 
38
 
39
  # --- Account Management Class ---
40
  class NotionAccount:
@@ -86,11 +96,7 @@ def authenticate(credentials: HTTPAuthorizationCredentials = Depends(security)):
86
  # --- Notion Account Management ---
87
 
88
  def get_next_account() -> NotionAccount:
89
- """
90
- Selects the next healthy Notion account using a round-robin strategy.
91
- This function is not async and relies on Python's GIL for atomic index updates,
92
- which is safe in a single-threaded asyncio environment.
93
- """
94
  global CURRENT_ACCOUNT_INDEX
95
 
96
  # This check runs on every request to ensure we don't try to select from an empty list
@@ -108,38 +114,29 @@ def get_next_account() -> NotionAccount:
108
  detail="No healthy Notion accounts available to process the request."
109
  )
110
 
111
- # Round-robin logic: iterate through all accounts to find the next healthy one
112
- # This ensures that we can recover if an account becomes healthy again later.
113
- # A lock is not strictly necessary in asyncio for a simple index increment,
114
- # but could be added for thread-safety if using a threaded server.
115
  start_index = CURRENT_ACCOUNT_INDEX
116
  while True:
117
  account = ACCOUNTS[CURRENT_ACCOUNT_INDEX]
118
  CURRENT_ACCOUNT_INDEX = (CURRENT_ACCOUNT_INDEX + 1) % len(ACCOUNTS)
119
  if account.is_healthy:
120
- logging.info(f"Selected Notion account: {account.user_email} (User ID: {account.user_id}) for request.")
121
  return account
122
- # This check prevents an infinite loop if no accounts are healthy,
123
- # although the initial check for healthy_accounts should prevent this.
124
  if CURRENT_ACCOUNT_INDEX == start_index:
125
- # This part should theoretically not be reached.
126
  raise HTTPException(
127
  status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
128
  detail="Critical error in account selection: No healthy accounts found in rotation."
129
  )
130
 
131
- # --- FastAPI App ---
132
-
133
  async def fetch_and_set_notion_ids(account: NotionAccount):
134
  """Fetches space ID and user ID for a given Notion account and marks it as healthy on success."""
135
  async with account.lock: # Ensure only one fetch operation per account at a time
136
  if account.is_healthy: # Don't re-fetch if already healthy
137
- logging.info(f"Account for user {account.user_id} is already healthy, skipping fetch.")
138
  return
139
 
140
  if not account.cookie:
141
- logging.error("Cannot fetch Notion IDs: Account cookie is not set.")
142
- logging.error(f"Failing cookie (empty): {account.cookie}")
143
  account.is_healthy = False
144
  return
145
 
@@ -150,10 +147,10 @@ async def fetch_and_set_notion_ids(account: NotionAccount):
150
  'accept': '*/*',
151
  'accept-language': 'en-US,en;q=0.9',
152
  'notion-audit-log-platform': 'web',
153
- 'notion-client-version': '23.13.0.3686', # Match cURL example or use a recent one
154
  'origin': 'https://www.notion.so',
155
  'priority': 'u=1, i',
156
- 'referer': 'https://www.notion.so/', # Simplified
157
  'sec-ch-ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
158
  'sec-ch-ua-mobile': '?0',
159
  'sec-ch-ua-platform': '"Windows"',
@@ -168,7 +165,6 @@ async def fetch_and_set_notion_ids(account: NotionAccount):
168
  page = None
169
 
170
  try:
171
- logging.info(f"Attempting to fetch Notion user/space IDs for an account...")
172
  async with async_playwright() as p:
173
  # Configure browser launch with proxy if PROXY_URL is set
174
  launch_args = {
@@ -177,17 +173,8 @@ async def fetch_and_set_notion_ids(account: NotionAccount):
177
  }
178
  if PROXY_URL:
179
  launch_args['proxy'] = {'server': PROXY_URL}
180
- logging.info(f"Using proxy for browser launch: {PROXY_URL}")
181
-
182
- try:
183
- browser = await p.chromium.launch(**launch_args)
184
- except PlaywrightError as e:
185
- if PROXY_URL and "proxy" in str(e).lower():
186
- logging.error(f"Invalid proxy URL or proxy connection failed: {PROXY_URL}. Error: {e}")
187
- raise PlaywrightError(f"Proxy configuration error: {e}")
188
- else:
189
- raise
190
 
 
191
  context = await browser.new_context(user_agent=js_fetch_headers['user-agent'])
192
 
193
  # Add cookies from the account's cookie string
@@ -204,18 +191,13 @@ async def fetch_and_set_notion_ids(account: NotionAccount):
204
  if cookies_to_add:
205
  await context.add_cookies(cookies_to_add)
206
  else:
207
- logging.error("No valid cookies parsed from account's cookie for getSpaces.")
208
- logging.error(f"Failing cookie: {account.cookie}")
209
  account.is_healthy = False
210
  return
211
 
212
  page = await context.new_page()
213
- logging.info("DEBUG: getSpaces - Navigating to notion.so to warm up context...")
214
- try:
215
- await page.goto("https://www.notion.so/", wait_until="domcontentloaded", timeout=15000) # 15s timeout
216
- logging.info("DEBUG: getSpaces - Warm-up navigation to notion.so complete.")
217
- except PlaywrightError as nav_err:
218
- logging.warning(f"DEBUG: getSpaces - Warm-up navigation to notion.so failed: {nav_err}. Proceeding with fetch anyway.")
219
 
220
  # JavaScript to perform the fetch for getSpaces
221
  javascript_code_get_spaces = """
@@ -241,32 +223,30 @@ async def fetch_and_set_notion_ids(account: NotionAccount):
241
  """
242
  js_args = {"apiUrl": get_spaces_url, "headers": js_fetch_headers, "body": {}} # Empty JSON body for getSpaces
243
 
244
- logging.info("Executing Playwright page.evaluate for getSpaces...")
245
  result = await page.evaluate(javascript_code_get_spaces, js_args)
246
 
247
  if not result or not result.get('success'):
248
  error_detail = result.get('error', 'Unknown error during getSpaces JS execution')
249
- logging.error(f"Playwright getSpaces call failed for account: {error_detail}")
250
- logging.error(f"Failing cookie: {account.cookie}")
251
  account.is_healthy = False
252
  return
253
 
254
  data = result.get('data')
255
  if not data:
256
- logging.error("No data returned from successful getSpaces call for account.")
257
- logging.error(f"Failing cookie: {account.cookie}")
258
  account.is_healthy = False
259
  return
260
 
261
  # Extract user ID
262
  user_id_key = next(iter(data), None)
263
  if not user_id_key:
264
- logging.error("Could not extract user ID from getSpaces response for account.")
265
- logging.error(f"Failing cookie: {account.cookie}")
266
  account.is_healthy = False
267
  return
268
  account.user_id = user_id_key
269
- logging.info(f"Fetched Notion User ID: {account.user_id}")
270
 
271
  # Extract space ID
272
  user_root = data.get(user_id_key, {}).get("user_root", {}).get(user_id_key, {})
@@ -310,90 +290,76 @@ async def fetch_and_set_notion_ids(account: NotionAccount):
310
  """
311
  analytics_args = {"apiUrl": get_user_analytics_url, "headers": analytics_headers, "body": {}}
312
 
313
- logging.info(f"Executing Playwright to get email for User ID: {account.user_id}...")
314
  analytics_result = await page.evaluate(javascript_code_get_analytics, analytics_args)
315
 
316
  if analytics_result and analytics_result.get('success'):
317
  analytics_data = analytics_result.get('data')
318
  account.user_email = analytics_data.get('user_email')
319
  if account.user_email:
320
- logging.info(f"Fetched User Email: {account.user_email} for User ID: {account.user_id}")
321
- logging.info(f"Fetched Notion Space ID: {account.space_id} for User ID: {account.user_id}")
322
  account.is_healthy = True # Mark as healthy only on complete success
323
  else:
324
- logging.error(f"Could not extract user_email for User ID: {account.user_id}")
325
- logging.error(f"Failing cookie: {account.cookie}")
326
  account.is_healthy = False
327
  else:
328
  error_detail = analytics_result.get('error', 'Unknown error') if analytics_result else 'No result from JS'
329
- logging.error(f"getUserAnalyticsSettings call failed for User ID {account.user_id}: {error_detail}")
330
- logging.error(f"Failing cookie: {account.cookie}")
331
  account.is_healthy = False
332
  else:
333
- logging.error(f"Could not extract spaceId for User ID: {account.user_id}")
334
- logging.error(f"Failing cookie: {account.cookie}")
335
  account.is_healthy = False
336
  else:
337
- logging.error(f"Could not find space_view_pointers or spaceId for User ID: {account.user_id}")
338
- logging.error(f"Failing cookie: {account.cookie}")
339
  account.is_healthy = False
340
 
341
  except PlaywrightError as e:
342
- logging.error(f"Playwright error during fetch_and_set_notion_ids for account: {e}")
343
- logging.error(f"Failing cookie: {account.cookie}")
344
  account.is_healthy = False
345
  except Exception as e:
346
- logging.error(f"General error during fetch_and_set_notion_ids for account: {e}")
347
- logging.error(f"Failing cookie: {account.cookie}")
348
  account.is_healthy = False
349
  finally:
350
- # Prioritize closing the browser, which should handle its contexts/pages.
351
- # Add checks to prevent errors if already closed.
352
  if browser and browser.is_connected():
353
  try:
354
- logging.info("DEBUG: fetch_and_set_notion_ids - Closing browser...")
355
  await browser.close()
356
- logging.info("DEBUG: fetch_and_set_notion_ids - Browser closed.")
357
- except PlaywrightError as e:
358
- logging.warning(f"DEBUG: fetch_and_set_notion_ids - Ignoring error during browser close: {e}")
359
- except Exception as e: # Catch potential unexpected errors during close
360
- logging.warning(f"DEBUG: fetch_and_set_notion_ids - Ignoring unexpected error during browser close: {e}")
361
- else:
362
- # If browser is None or not connected, page/context are likely also invalid or already handled.
363
- logging.info("DEBUG: fetch_and_set_notion_ids - Browser already closed or not initialized.")
364
-
365
- logging.info(f"fetch_and_set_notion_ids completed for account. Final status: {account}")
366
-
367
 
368
  @asynccontextmanager
369
  async def lifespan(app: FastAPI):
370
  # On startup
371
- logging.info("Application startup: Initializing Notion accounts...")
372
 
373
  if NOTION_COOKIES_RAW:
374
  # Split cookies by a unique separator, e.g., '|'
375
  cookie_list = [c.strip() for c in NOTION_COOKIES_RAW.split('|') if c.strip()]
376
  for cookie in cookie_list:
377
  ACCOUNTS.append(NotionAccount(cookie=cookie))
378
- logging.info(f"Loaded {len(ACCOUNTS)} Notion account(s) from environment variable.")
379
 
380
  if not ACCOUNTS:
381
- logging.error("CRITICAL: No Notion accounts loaded. The application will not be able to process requests.")
382
  else:
383
- # Concurrently fetch IDs for all accounts
384
- logging.info("Fetching IDs for all loaded Notion accounts...")
385
  fetch_tasks = [fetch_and_set_notion_ids(acc) for acc in ACCOUNTS]
386
  await asyncio.gather(*fetch_tasks)
387
 
388
  healthy_count = sum(1 for acc in ACCOUNTS if acc.is_healthy)
389
- logging.info(f"Initialization complete. {healthy_count} of {len(ACCOUNTS)} accounts are healthy.")
390
 
391
  if healthy_count == 0:
392
- logging.error("CRITICAL: No healthy Notion accounts available after initialization.")
393
 
394
  yield
395
- # On shutdown (if any cleanup needed)
396
- logging.info("Application shutdown.")
397
 
398
  app = FastAPI(lifespan=lifespan)
399
 
@@ -541,12 +507,10 @@ async def _run_playwright_fetch(
541
 
542
  # Construct headers for this specific task run
543
  current_headers = headers_template.copy()
544
- current_headers['x-notion-space-id'] = account.space_id # Use fetched space_id
545
- if account.user_id: # Use fetched user_id for active user header
546
  current_headers['x-notion-active-user-header'] = account.user_id
547
 
548
- # 'cookie' is handled by context.add_cookies(), so it's not in current_headers for fetch
549
-
550
  async def handle_chunk(chunk_str: str):
551
  await chunk_queue.put(chunk_str)
552
 
@@ -554,7 +518,6 @@ async def _run_playwright_fetch(
554
  await chunk_queue.put(None)
555
 
556
  try:
557
- logging.info("DEBUG: Background task starting Playwright.")
558
  async with async_playwright() as p:
559
  # Configure browser launch with proxy if PROXY_URL is set
560
  launch_args = {
@@ -563,25 +526,11 @@ async def _run_playwright_fetch(
563
  }
564
  if PROXY_URL:
565
  launch_args['proxy'] = {'server': PROXY_URL}
566
- logging.info(f"DEBUG: Background task using proxy: {PROXY_URL}")
567
-
568
- try:
569
- browser = await p.chromium.launch(**launch_args)
570
- except PlaywrightError as e:
571
- if PROXY_URL and "proxy" in str(e).lower():
572
- logging.error(f"Invalid proxy URL or proxy connection failed: {PROXY_URL}. Error: {e}")
573
- await handle_stream_end() # Signal end of stream
574
- raise PlaywrightError(f"Proxy configuration error: {e}")
575
- else:
576
- raise
577
 
578
- logging.info("DEBUG: Background task browser launched.")
579
- # Get user-agent from the constructed headers for this task
580
- user_agent_for_context = current_headers.get('user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36') # Default if not in template
581
- context = await browser.new_context(user_agent=user_agent_for_context)
582
- logging.info("DEBUG: Background task context created.")
583
 
584
- if account.cookie: # Use passed account cookie
585
  cookies_to_add = []
586
  cookie_pairs = account.cookie.split('; ')
587
  for pair in cookie_pairs:
@@ -594,30 +543,24 @@ async def _run_playwright_fetch(
594
  })
595
  if cookies_to_add:
596
  await context.add_cookies(cookies_to_add)
597
- logging.info("DEBUG: Background task cookies added.")
598
  else:
599
- logging.warning("Warning: No valid cookies found in account cookie for background task.")
600
  else:
601
- logging.error("Error: Account cookie is empty for background task.")
602
- raise ValueError("Server configuration error: Notion cookie not set for background task.")
603
 
604
  page = await context.new_page()
605
- logging.info("DEBUG: Background task page created.")
606
  await page.goto("https://www.notion.so/chat", wait_until="domcontentloaded")
607
- logging.info("DEBUG: Background task navigation complete.")
608
 
609
  await page.expose_function("sendChunkToPython", handle_chunk)
610
  await page.expose_function("signalStreamEnd", handle_stream_end)
611
- logging.info("DEBUG: Background task functions exposed.")
612
 
613
  request_body_json_str = notion_request_body.json()
614
 
615
  # Prepare headers for JS fetch (cookie is handled by context)
616
  js_fetch_headers = current_headers.copy()
617
- if 'cookie' in js_fetch_headers: # Should not be there if template is correct
618
  del js_fetch_headers['cookie']
619
 
620
-
621
  javascript_code = """
622
  async (args) => {
623
  const { apiUrl, headers, body } = args;
@@ -655,31 +598,23 @@ async def _run_playwright_fetch(
655
  }
656
  """
657
  js_args = {"apiUrl": notion_api_url, "headers": js_fetch_headers, "body": request_body_json_str}
658
- logging.info("DEBUG: Background task executing page.evaluate()...")
659
  js_result = await page.evaluate(javascript_code, js_args)
660
- logging.info(f"DEBUG: Background task page.evaluate() result: {js_result}")
661
 
662
  if not js_result or not js_result.get('success'):
663
  error_detail = js_result.get('error', 'Unknown JS execution error')
664
- logging.error(f"Error in background task JS execution: {error_detail}")
665
  # Error already signaled to queue by JS calling signalStreamEnd
666
  # Re-raise to be caught by the task's main try/except
667
  raise PlaywrightError(f"JS Fetch Error: {error_detail}")
668
 
669
  except Exception as e:
670
- logging.error(f"Error in _run_playwright_fetch background task: {e}")
671
  await chunk_queue.put(None) # Ensure queue is terminated on error
672
  # Exception will be caught by playwright_task.exception() in the main generator
673
  finally:
674
- logging.info("DEBUG: Background task _run_playwright_fetch attempting to close browser.")
675
  if browser and browser.is_connected():
676
  try:
677
  await browser.close()
678
- logging.info("DEBUG: Background task browser closed.")
679
- except Exception as e:
680
- logging.warning(f"Ignoring error during background task browser close: {e}")
681
- else:
682
- logging.info("DEBUG: Background task browser already closed or not initialized.")
683
 
684
  # --- Main Generator Called by Endpoint ---
685
  async def stream_notion_response(notion_request_body: NotionRequestBody, account: NotionAccount):
@@ -692,8 +627,6 @@ async def stream_notion_response(notion_request_body: NotionRequestBody, account
692
  created_time = int(time.time())
693
 
694
  # Define the template for headers here, to be passed to the background task
695
- # The background task will then add/override specific headers like x-notion-space-id
696
- # It will also fetch NOTION_ACTIVE_USER_HEADER from os.getenv()
697
  headers_template = {
698
  'accept': 'application/x-ndjson',
699
  'accept-language': 'en-US,en;q=0.9',
@@ -711,12 +644,10 @@ async def stream_notion_response(notion_request_body: NotionRequestBody, account
711
  'sec-fetch-site': 'same-origin',
712
  'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
713
  # 'cookie' and 'x-notion-space-id' will be handled/added by _run_playwright_fetch
714
- # using the passed environment variable strings
715
  }
716
 
717
  try:
718
  # Health check is now performed by get_next_account() before this function is called.
719
- logging.info("DEBUG: Main generator starting Playwright background task.")
720
  playwright_task = asyncio.create_task(
721
  _run_playwright_fetch(
722
  chunk_queue,
@@ -728,11 +659,9 @@ async def stream_notion_response(notion_request_body: NotionRequestBody, account
728
  )
729
 
730
  accumulated_line = ""
731
- logging.info("DEBUG: Main generator starting queue processing loop.")
732
  while True:
733
  chunk = await chunk_queue.get() # Wait for a chunk from the background task
734
  if chunk is None:
735
- logging.info("DEBUG: Main generator received None sentinel from queue.")
736
  break
737
 
738
  accumulated_line += chunk
@@ -749,15 +678,14 @@ async def stream_notion_response(notion_request_body: NotionRequestBody, account
749
  id=chunk_id, created=created_time,
750
  choices=[Choice(delta=ChoiceDelta(content=content_chunk))]
751
  )
752
- logging.info(f"DEBUG: Main generator yielding chunk: {content_chunk[:50]}...")
753
  yield f"data: {sse_chunk.json()}\n\n"
754
- # No asyncio.sleep(0) here, as yielding should be enough
755
  elif "recordMap" in data:
756
- logging.info("DEBUG: Main generator detected recordMap, ignoring.")
757
  except json.JSONDecodeError:
758
- logging.warning(f"Warning: Main generator could not decode JSON line: {line}")
759
  except Exception as e:
760
- logging.error(f"Error processing line in main generator: {line} - {e}")
 
761
 
762
  # Process any final accumulated data after None sentinel
763
  if accumulated_line.strip():
@@ -770,47 +698,40 @@ async def stream_notion_response(notion_request_body: NotionRequestBody, account
770
  id=chunk_id, created=created_time,
771
  choices=[Choice(delta=ChoiceDelta(content=content_chunk))]
772
  )
773
- logging.info(f"DEBUG: Main generator yielding final accumulated chunk: {content_chunk[:50]}...")
774
  yield f"data: {sse_chunk.json()}\n\n"
775
  except json.JSONDecodeError:
776
- logging.warning(f"Warning: Main generator could not decode final JSON line: {accumulated_line}")
777
  except Exception as e:
778
- logging.error(f"Error processing final line in main generator: {accumulated_line} - {e}")
 
779
 
780
  # After loop, check if the background task raised an exception
781
  if playwright_task.done() and playwright_task.exception():
782
  task_exception = playwright_task.exception()
783
- logging.error(f"Playwright background task failed: {task_exception}")
784
- raise HTTPException(status_code=500, detail=f"Error during background browser automation: {task_exception}")
785
  else:
786
- logging.info("DEBUG: Main generator background task completed successfully.")
787
  final_chunk = ChatCompletionChunk(
788
  id=chunk_id, created=created_time,
789
  choices=[Choice(delta=ChoiceDelta(), finish_reason="stop")]
790
  )
791
- logging.info("DEBUG: Main generator yielding final stop chunk.")
792
  yield f"data: {final_chunk.json()}\n\n"
793
- logging.info("DEBUG: Main generator yielding [DONE] marker.")
794
  yield "data: [DONE]\n\n"
795
 
796
  except Exception as e:
797
- logging.error(f"Error in main stream_notion_response generator: {e}")
798
  if playwright_task and not playwright_task.done():
799
- logging.info("DEBUG: Main generator cancelling background task due to its own error.")
800
  playwright_task.cancel()
801
  raise
802
  finally:
803
- logging.info("DEBUG: Main generator finished.")
804
  if playwright_task and not playwright_task.done():
805
- logging.info("DEBUG: Main generator ensuring background task is cancelled on exit.")
806
  playwright_task.cancel()
807
  try:
808
  await playwright_task # Allow cancellation to propagate
809
  except asyncio.CancelledError:
810
- logging.info("DEBUG: Background task successfully cancelled.")
811
- except Exception as e:
812
- logging.error(f"DEBUG: Error during background task cancellation/await: {e}")
813
-
814
 
815
  # --- API Endpoint ---
816
 
@@ -847,16 +768,12 @@ async def chat_completions(request_data: ChatCompletionRequest, request: Request
847
  media_type="text/event-stream"
848
  )
849
  else:
850
- # --- Non-Streaming Logic (Optional - Collects stream internally) ---
851
- # Note: The primary goal is streaming, but a non-streaming version
852
- # might be useful for testing or simpler clients.
853
- # This requires collecting all chunks from the async generator.
854
  full_response_content = ""
855
  final_finish_reason = None
856
  chunk_id = f"chatcmpl-{uuid.uuid4()}" # Generate ID for the non-streamed response
857
  created_time = int(time.time())
858
 
859
- # --- Non-streaming logic needs to call the generator with the selected account ---
860
  try:
861
  # Call the Playwright generator, passing the selected account
862
  async for line in stream_notion_response(notion_request_body, account):
@@ -874,7 +791,9 @@ async def chat_completions(request_data: ChatCompletionRequest, request: Request
874
  if finish_reason:
875
  final_finish_reason = finish_reason
876
  except json.JSONDecodeError:
877
- print(f"Warning: Could not decode JSON line in non-streaming mode: {line}")
 
 
878
 
879
  # Construct the final OpenAI-compatible non-streaming response
880
  return {
@@ -902,13 +821,10 @@ async def chat_completions(request_data: ChatCompletionRequest, request: Request
902
  # Re-raise HTTP exceptions from the streaming function
903
  raise e
904
  except Exception as e:
905
- print(f"Error during non-streaming processing: {e}")
906
  raise HTTPException(status_code=500, detail="Internal server error processing Notion response")
907
 
908
-
909
  # --- Uvicorn Runner ---
910
- # Allows running with `python main.py` for simple testing,
911
- # but `uvicorn main:app --reload` is recommended for development.
912
  if __name__ == "__main__":
913
  import uvicorn
914
  print("Starting server. Access at http://127.0.0.1:7860")
 
34
  logging.info("Set WindowsProactorEventLoopPolicy for asyncio.")
35
 
36
  # --- Logging Configuration ---
37
+ # Change logging level from INFO to WARNING to reduce verbosity
38
+ # Only important messages and errors will be displayed
39
+ logging.basicConfig(
40
+ level=logging.WARNING, # Changed from INFO to WARNING
41
+ format='%(asctime)s - %(levelname)s - %(message)s',
42
+ datefmt='%Y-%m-%d %H:%M:%S' # Standardize date format
43
+ )
44
+
45
+ # Create a custom logger for critical account operations that we always want to see
46
+ account_logger = logging.getLogger("notion_account")
47
+ account_logger.setLevel(logging.INFO)
48
 
49
  # --- Account Management Class ---
50
  class NotionAccount:
 
96
  # --- Notion Account Management ---
97
 
98
  def get_next_account() -> NotionAccount:
99
+ """Selects the next healthy Notion account using a round-robin strategy."""
 
 
 
 
100
  global CURRENT_ACCOUNT_INDEX
101
 
102
  # This check runs on every request to ensure we don't try to select from an empty list
 
114
  detail="No healthy Notion accounts available to process the request."
115
  )
116
 
117
+ # Round-robin logic
 
 
 
118
  start_index = CURRENT_ACCOUNT_INDEX
119
  while True:
120
  account = ACCOUNTS[CURRENT_ACCOUNT_INDEX]
121
  CURRENT_ACCOUNT_INDEX = (CURRENT_ACCOUNT_INDEX + 1) % len(ACCOUNTS)
122
  if account.is_healthy:
123
+ account_logger.warning(f"Using account: {account.user_email}")
124
  return account
 
 
125
  if CURRENT_ACCOUNT_INDEX == start_index:
 
126
  raise HTTPException(
127
  status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
128
  detail="Critical error in account selection: No healthy accounts found in rotation."
129
  )
130
 
 
 
131
  async def fetch_and_set_notion_ids(account: NotionAccount):
132
  """Fetches space ID and user ID for a given Notion account and marks it as healthy on success."""
133
  async with account.lock: # Ensure only one fetch operation per account at a time
134
  if account.is_healthy: # Don't re-fetch if already healthy
 
135
  return
136
 
137
  if not account.cookie:
138
+ account_logger.error("Account validation failed: Cookie not set")
139
+ account_logger.error(f"Failing cookie (empty): {account.cookie}")
140
  account.is_healthy = False
141
  return
142
 
 
147
  'accept': '*/*',
148
  'accept-language': 'en-US,en;q=0.9',
149
  'notion-audit-log-platform': 'web',
150
+ 'notion-client-version': '23.13.0.3686',
151
  'origin': 'https://www.notion.so',
152
  'priority': 'u=1, i',
153
+ 'referer': 'https://www.notion.so/',
154
  'sec-ch-ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
155
  'sec-ch-ua-mobile': '?0',
156
  'sec-ch-ua-platform': '"Windows"',
 
165
  page = None
166
 
167
  try:
 
168
  async with async_playwright() as p:
169
  # Configure browser launch with proxy if PROXY_URL is set
170
  launch_args = {
 
173
  }
174
  if PROXY_URL:
175
  launch_args['proxy'] = {'server': PROXY_URL}
 
 
 
 
 
 
 
 
 
 
176
 
177
+ browser = await p.chromium.launch(**launch_args)
178
  context = await browser.new_context(user_agent=js_fetch_headers['user-agent'])
179
 
180
  # Add cookies from the account's cookie string
 
191
  if cookies_to_add:
192
  await context.add_cookies(cookies_to_add)
193
  else:
194
+ account_logger.error("Account validation failed: No valid cookies parsed")
195
+ account_logger.error(f"Failing cookie: {account.cookie}")
196
  account.is_healthy = False
197
  return
198
 
199
  page = await context.new_page()
200
+ await page.goto("https://www.notion.so/", wait_until="domcontentloaded", timeout=15000)
 
 
 
 
 
201
 
202
  # JavaScript to perform the fetch for getSpaces
203
  javascript_code_get_spaces = """
 
223
  """
224
  js_args = {"apiUrl": get_spaces_url, "headers": js_fetch_headers, "body": {}} # Empty JSON body for getSpaces
225
 
 
226
  result = await page.evaluate(javascript_code_get_spaces, js_args)
227
 
228
  if not result or not result.get('success'):
229
  error_detail = result.get('error', 'Unknown error during getSpaces JS execution')
230
+ account_logger.error(f"Account validation failed: {error_detail}")
231
+ account_logger.error(f"Failing cookie: {account.cookie}")
232
  account.is_healthy = False
233
  return
234
 
235
  data = result.get('data')
236
  if not data:
237
+ account_logger.error("Account validation failed: No data returned")
238
+ account_logger.error(f"Failing cookie: {account.cookie}")
239
  account.is_healthy = False
240
  return
241
 
242
  # Extract user ID
243
  user_id_key = next(iter(data), None)
244
  if not user_id_key:
245
+ account_logger.error("Account validation failed: No user ID found")
246
+ account_logger.error(f"Failing cookie: {account.cookie}")
247
  account.is_healthy = False
248
  return
249
  account.user_id = user_id_key
 
250
 
251
  # Extract space ID
252
  user_root = data.get(user_id_key, {}).get("user_root", {}).get(user_id_key, {})
 
290
  """
291
  analytics_args = {"apiUrl": get_user_analytics_url, "headers": analytics_headers, "body": {}}
292
 
 
293
  analytics_result = await page.evaluate(javascript_code_get_analytics, analytics_args)
294
 
295
  if analytics_result and analytics_result.get('success'):
296
  analytics_data = analytics_result.get('data')
297
  account.user_email = analytics_data.get('user_email')
298
  if account.user_email:
299
+ account_logger.warning(f"Account validated: {account.user_email}")
 
300
  account.is_healthy = True # Mark as healthy only on complete success
301
  else:
302
+ account_logger.error(f"Account validation failed: No email found for User ID: {account.user_id}")
303
+ account_logger.error(f"Failing cookie: {account.cookie}")
304
  account.is_healthy = False
305
  else:
306
  error_detail = analytics_result.get('error', 'Unknown error') if analytics_result else 'No result from JS'
307
+ account_logger.error(f"Account validation failed: getUserAnalyticsSettings error: {error_detail}")
308
+ account_logger.error(f"Failing cookie: {account.cookie}")
309
  account.is_healthy = False
310
  else:
311
+ account_logger.error(f"Account validation failed: No spaceId for User ID: {account.user_id}")
312
+ account_logger.error(f"Failing cookie: {account.cookie}")
313
  account.is_healthy = False
314
  else:
315
+ account_logger.error(f"Account validation failed: No space_view_pointers for User ID: {account.user_id}")
316
+ account_logger.error(f"Failing cookie: {account.cookie}")
317
  account.is_healthy = False
318
 
319
  except PlaywrightError as e:
320
+ account_logger.error(f"Account validation failed: Playwright error: {e}")
321
+ account_logger.error(f"Failing cookie: {account.cookie}")
322
  account.is_healthy = False
323
  except Exception as e:
324
+ account_logger.error(f"Account validation failed: General error: {e}")
325
+ account_logger.error(f"Failing cookie: {account.cookie}")
326
  account.is_healthy = False
327
  finally:
 
 
328
  if browser and browser.is_connected():
329
  try:
 
330
  await browser.close()
331
+ except:
332
+ pass # Suppress any errors during browser close
 
 
 
 
 
 
 
 
 
333
 
334
  @asynccontextmanager
335
  async def lifespan(app: FastAPI):
336
  # On startup
337
+ account_logger.warning("Starting application: Initializing Notion accounts...")
338
 
339
  if NOTION_COOKIES_RAW:
340
  # Split cookies by a unique separator, e.g., '|'
341
  cookie_list = [c.strip() for c in NOTION_COOKIES_RAW.split('|') if c.strip()]
342
  for cookie in cookie_list:
343
  ACCOUNTS.append(NotionAccount(cookie=cookie))
344
+ account_logger.warning(f"Loaded {len(ACCOUNTS)} Notion account(s) from environment variable.")
345
 
346
  if not ACCOUNTS:
347
+ account_logger.error("CRITICAL: No Notion accounts loaded. The application will not be able to process requests.")
348
  else:
349
+ # Concurrently fetch IDs for all loaded Notion accounts...
350
+ account_logger.warning("Fetching IDs for all Notion accounts...")
351
  fetch_tasks = [fetch_and_set_notion_ids(acc) for acc in ACCOUNTS]
352
  await asyncio.gather(*fetch_tasks)
353
 
354
  healthy_count = sum(1 for acc in ACCOUNTS if acc.is_healthy)
355
+ account_logger.warning(f"Initialization complete. {healthy_count} of {len(ACCOUNTS)} accounts are healthy.")
356
 
357
  if healthy_count == 0:
358
+ account_logger.error("CRITICAL: No healthy Notion accounts available after initialization.")
359
 
360
  yield
361
+ # On shutdown
362
+ account_logger.warning("Application shutdown.")
363
 
364
  app = FastAPI(lifespan=lifespan)
365
 
 
507
 
508
  # Construct headers for this specific task run
509
  current_headers = headers_template.copy()
510
+ current_headers['x-notion-space-id'] = account.space_id
511
+ if account.user_id:
512
  current_headers['x-notion-active-user-header'] = account.user_id
513
 
 
 
514
  async def handle_chunk(chunk_str: str):
515
  await chunk_queue.put(chunk_str)
516
 
 
518
  await chunk_queue.put(None)
519
 
520
  try:
 
521
  async with async_playwright() as p:
522
  # Configure browser launch with proxy if PROXY_URL is set
523
  launch_args = {
 
526
  }
527
  if PROXY_URL:
528
  launch_args['proxy'] = {'server': PROXY_URL}
 
 
 
 
 
 
 
 
 
 
 
529
 
530
+ browser = await p.chromium.launch(**launch_args)
531
+ context = await browser.new_context(user_agent=current_headers.get('user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'))
 
 
 
532
 
533
+ if account.cookie:
534
  cookies_to_add = []
535
  cookie_pairs = account.cookie.split('; ')
536
  for pair in cookie_pairs:
 
543
  })
544
  if cookies_to_add:
545
  await context.add_cookies(cookies_to_add)
 
546
  else:
547
+ raise ValueError("Server configuration error: No valid cookies for request")
548
  else:
549
+ raise ValueError("Server configuration error: Notion cookie not set for request")
 
550
 
551
  page = await context.new_page()
 
552
  await page.goto("https://www.notion.so/chat", wait_until="domcontentloaded")
 
553
 
554
  await page.expose_function("sendChunkToPython", handle_chunk)
555
  await page.expose_function("signalStreamEnd", handle_stream_end)
 
556
 
557
  request_body_json_str = notion_request_body.json()
558
 
559
  # Prepare headers for JS fetch (cookie is handled by context)
560
  js_fetch_headers = current_headers.copy()
561
+ if 'cookie' in js_fetch_headers:
562
  del js_fetch_headers['cookie']
563
 
 
564
  javascript_code = """
565
  async (args) => {
566
  const { apiUrl, headers, body } = args;
 
598
  }
599
  """
600
  js_args = {"apiUrl": notion_api_url, "headers": js_fetch_headers, "body": request_body_json_str}
 
601
  js_result = await page.evaluate(javascript_code, js_args)
 
602
 
603
  if not js_result or not js_result.get('success'):
604
  error_detail = js_result.get('error', 'Unknown JS execution error')
 
605
  # Error already signaled to queue by JS calling signalStreamEnd
606
  # Re-raise to be caught by the task's main try/except
607
  raise PlaywrightError(f"JS Fetch Error: {error_detail}")
608
 
609
  except Exception as e:
 
610
  await chunk_queue.put(None) # Ensure queue is terminated on error
611
  # Exception will be caught by playwright_task.exception() in the main generator
612
  finally:
 
613
  if browser and browser.is_connected():
614
  try:
615
  await browser.close()
616
+ except:
617
+ pass # Suppress any errors during browser close
 
 
 
618
 
619
  # --- Main Generator Called by Endpoint ---
620
  async def stream_notion_response(notion_request_body: NotionRequestBody, account: NotionAccount):
 
627
  created_time = int(time.time())
628
 
629
  # Define the template for headers here, to be passed to the background task
 
 
630
  headers_template = {
631
  'accept': 'application/x-ndjson',
632
  'accept-language': 'en-US,en;q=0.9',
 
644
  'sec-fetch-site': 'same-origin',
645
  'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
646
  # 'cookie' and 'x-notion-space-id' will be handled/added by _run_playwright_fetch
 
647
  }
648
 
649
  try:
650
  # Health check is now performed by get_next_account() before this function is called.
 
651
  playwright_task = asyncio.create_task(
652
  _run_playwright_fetch(
653
  chunk_queue,
 
659
  )
660
 
661
  accumulated_line = ""
 
662
  while True:
663
  chunk = await chunk_queue.get() # Wait for a chunk from the background task
664
  if chunk is None:
 
665
  break
666
 
667
  accumulated_line += chunk
 
678
  id=chunk_id, created=created_time,
679
  choices=[Choice(delta=ChoiceDelta(content=content_chunk))]
680
  )
 
681
  yield f"data: {sse_chunk.json()}\n\n"
 
682
  elif "recordMap" in data:
683
+ pass # Ignore recordMap chunks
684
  except json.JSONDecodeError:
685
+ pass # Ignore JSON decode errors in silent mode
686
  except Exception as e:
687
+ # Only log critical errors
688
+ account_logger.error(f"Critical error processing stream: {e}")
689
 
690
  # Process any final accumulated data after None sentinel
691
  if accumulated_line.strip():
 
698
  id=chunk_id, created=created_time,
699
  choices=[Choice(delta=ChoiceDelta(content=content_chunk))]
700
  )
 
701
  yield f"data: {sse_chunk.json()}\n\n"
702
  except json.JSONDecodeError:
703
+ pass # Ignore JSON decode errors in silent mode
704
  except Exception as e:
705
+ # Only log critical errors
706
+ account_logger.error(f"Critical error processing final data: {e}")
707
 
708
  # After loop, check if the background task raised an exception
709
  if playwright_task.done() and playwright_task.exception():
710
  task_exception = playwright_task.exception()
711
+ account_logger.error(f"Background task error: {task_exception}")
712
+ raise HTTPException(status_code=500, detail=f"Error during background processing: {task_exception}")
713
  else:
 
714
  final_chunk = ChatCompletionChunk(
715
  id=chunk_id, created=created_time,
716
  choices=[Choice(delta=ChoiceDelta(), finish_reason="stop")]
717
  )
 
718
  yield f"data: {final_chunk.json()}\n\n"
 
719
  yield "data: [DONE]\n\n"
720
 
721
  except Exception as e:
722
+ account_logger.error(f"Stream response error: {e}")
723
  if playwright_task and not playwright_task.done():
 
724
  playwright_task.cancel()
725
  raise
726
  finally:
 
727
  if playwright_task and not playwright_task.done():
 
728
  playwright_task.cancel()
729
  try:
730
  await playwright_task # Allow cancellation to propagate
731
  except asyncio.CancelledError:
732
+ pass
733
+ except Exception:
734
+ pass # Suppress errors during cleanup
 
735
 
736
  # --- API Endpoint ---
737
 
 
768
  media_type="text/event-stream"
769
  )
770
  else:
771
+ # --- Non-Streaming Logic ---
 
 
 
772
  full_response_content = ""
773
  final_finish_reason = None
774
  chunk_id = f"chatcmpl-{uuid.uuid4()}" # Generate ID for the non-streamed response
775
  created_time = int(time.time())
776
 
 
777
  try:
778
  # Call the Playwright generator, passing the selected account
779
  async for line in stream_notion_response(notion_request_body, account):
 
791
  if finish_reason:
792
  final_finish_reason = finish_reason
793
  except json.JSONDecodeError:
794
+ pass # Ignore JSON errors in non-streaming mode
795
+ except Exception as e:
796
+ account_logger.error(f"Error processing non-streaming response: {e}")
797
 
798
  # Construct the final OpenAI-compatible non-streaming response
799
  return {
 
821
  # Re-raise HTTP exceptions from the streaming function
822
  raise e
823
  except Exception as e:
824
+ account_logger.error(f"Error during non-streaming processing: {e}")
825
  raise HTTPException(status_code=500, detail="Internal server error processing Notion response")
826
 
 
827
  # --- Uvicorn Runner ---
 
 
828
  if __name__ == "__main__":
829
  import uvicorn
830
  print("Starting server. Access at http://127.0.0.1:7860")