From 098c32560156dec9befb877cc175908b68843ab6 Mon Sep 17 00:00:00 2001 From: Sunjeong Bae <102775904+rosebaesj@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:23:11 -0400 Subject: [PATCH] fix(automation): repair Oura login and export download Four failures currently make the automated sync unusable. 1. Login dies on the verification-method screen. Oura now offers a passkey alongside the e-mail code, and both buttons share name='selectedId', so the unqualified locator matches two elements and Playwright's strict mode raises "strict mode violation: ... resolved to 2 elements". Passkeys cannot be driven from an automated browser, so those options are dropped and an e-mail/code label is preferred among the rest, falling back to the last option. Excluding passkeys rather than matching the e-mail label keeps this working on non-English pages. 2. login() returned None when the session was already valid. The UI reads `.message` off that response unconditionally, so a successful login crashed the renderer with "Cannot read properties of null". It now always returns a status dict. 3. The export wait declared success immediately. _wait_for_processing() asked whether the Request button was enabled again, but Oura leaves it enabled right after it is clicked, and the generic 'main button' fallback matches almost any button on the page. It returned ready within seconds, and the caller then failed to find a download button that would not exist for hours, reporting "No file downloaded (timeout?)". It now polls for the download control itself, which is the only signal the archive exists. 4. download_export() passed a status dict to the zip parser when the automator could not reach the export page, surfacing as "'dict' object has no attribute 'seek'" instead of saying login was needed. Together these mean a full sync never completes: (1) blocks accounts with a passkey, and (3) fails every account regardless. Co-Authored-By: Claude Opus 5 --- backend/src/api/routes.py | 13 ++- backend/src/automation.py | 174 +++++++++++++++++++++++++++----------- 2 files changed, 137 insertions(+), 50 deletions(-) diff --git a/backend/src/api/routes.py b/backend/src/api/routes.py index 1fd285a..07b7135 100644 --- a/backend/src/api/routes.py +++ b/backend/src/api/routes.py @@ -195,9 +195,18 @@ async def download_export(db: Session = Depends(get_db)): with tempfile.TemporaryDirectory() as temp_dir: zip_path = await automator.download_existing_export(temp_dir) - if isinstance(zip_path, dict) and zip_path.get("status") == "error": + # download_existing_export() returns a status dict instead of a path + # when it cannot get to the export page. Passing that dict on to the + # parser used to surface as "'dict' object has no attribute 'seek'". + if isinstance(zip_path, dict): + if zip_path.get("status") == "otp_required": + raise HTTPException( + status_code=401, + detail="Login required: Oura sent a verification code. " + "Submit it before retrying the download.", + ) raise HTTPException(status_code=500, detail=f"Download failed: {zip_path.get('message')}") - + if not zip_path: raise HTTPException(status_code=500, detail="Download failed: Button not found or timeout.") diff --git a/backend/src/automation.py b/backend/src/automation.py index f7ffe03..f3a6f30 100644 --- a/backend/src/automation.py +++ b/backend/src/automation.py @@ -1,6 +1,7 @@ import asyncio import os import logging +import re from playwright.async_api import async_playwright, expect, Page, BrowserContext, Browser from typing import Optional, Dict, Any, Union @@ -10,6 +11,15 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger("OuraAutomator") +# Oura's "choose a verification method" screen renders one button per method and +# they all share name='selectedId'. Passkeys need a platform authenticator and +# can never work in an automated browser, so they are excluded first. +_PASSKEY_RE = re.compile(r"passkey|pass\s?key|\ud328\uc2a4\ud0a4|\u30d1\u30b9\u30ad\u30fc", re.I) +_EMAIL_CODE_RE = re.compile( + r"e-?mail|code|koodi|\ucf54\ub4dc|\u30b3\u30fc\u30c9|c\u00f3digo|codigo|kod", + re.I, +) + class OuraAutomator: """ Automates Oura Web Dashboard interactions using Playwright. @@ -153,10 +163,12 @@ async def save_context(self): # --- Login Logic --- - async def login(self) -> Union[None, Dict[str, str]]: + async def login(self) -> Dict[str, str]: """ Executes the login flow. - Returns None if already logged in, or a status dictionary if further action (like OTP) is needed. + + Always returns a status dictionary. Returning None here crashed the UI, + which reads `.message` off this response unconditionally. """ if not self.page: raise Exception("Page not initialized") @@ -174,7 +186,7 @@ async def login(self) -> Union[None, Dict[str, str]]: if self._is_logged_in(): logger.info("Already logged in.") await self.save_context() - return + return {"status": "success", "message": "Already logged in."} logger.info("Not logged in. Attempting login...") if not self.email: @@ -249,19 +261,53 @@ async def _click_submit(self): await self.page.wait_for_timeout(3000) + async def _find_verification_method_button(self): + """ + Picks the e-mail option on Oura's "choose a verification method" screen. + + Every method button shares name='selectedId', so an unqualified locator + matches several elements and trips Playwright's strict mode. Passkey + options are dropped because they cannot be driven from an automated + browser; among the rest an e-mail/code label wins, and otherwise we take + the last option, which is where Oura lists the fallback method. + """ + buttons = await self.page.locator("button[name='selectedId']").all() + + visible = [] + for btn in buttons: + try: + if await btn.is_visible(): + visible.append((btn, ((await btn.inner_text()) or "").strip())) + except Exception: + continue + + if not visible: + return None + + usable = [(b, t) for b, t in visible if not _PASSKEY_RE.search(t)] or visible + + for btn, text in usable: + if _EMAIL_CODE_RE.search(text): + logger.info(f"Verification method selected: {text!r}") + return btn + + btn, text = usable[-1] + logger.info(f"No e-mail label matched; falling back to: {text!r}") + return btn + async def _check_otp_screen(self): """Checks if OTP screen is active and handles the 'Send Code' intermediate step if present.""" - # Check for "Send code" intermediate page - intermediate_btn = self.page.locator("button[name='selectedId']") - otp_input_name = self.page.locator("input[name='otp']") - otp_input_id = self.page.locator("#otp-code") + otp_input_name = self.page.locator("input[name='otp']").first + otp_input_id = self.page.locator("#otp-code").first - if await intermediate_btn.is_visible() and \ - not await otp_input_name.is_visible() and \ + # Check for "Send code" intermediate page + if not await otp_input_name.is_visible() and \ not await otp_input_id.is_visible(): - logger.info("Found intermediate 'Send Code' button. Clicking...") - await intermediate_btn.click() - await self.page.wait_for_timeout(3000) + intermediate_btn = await self._find_verification_method_button() + if intermediate_btn is not None: + logger.info("Found intermediate 'Send Code' button. Clicking...") + await intermediate_btn.click() + await self.page.wait_for_timeout(3000) # Check for OTP input visibility if await otp_input_name.is_visible() or await otp_input_id.is_visible(): @@ -460,48 +506,80 @@ async def _click_request_export_button(self) -> bool: logger.error(f"Click failed: {e}") return False + async def _download_button(self): + """Returns a visible download control on the export page, or None.""" + selectors = [ + "button[aria-label='Download data']", + "a[aria-label='Download data']", + "button[aria-label*='ownload']", + "a[aria-label*='ownload']", + "a[download]", + ] + for selector in selectors: + locator = self.page.locator(selector).first + try: + if await locator.is_visible(): + return locator + except Exception: + continue + return None + async def _wait_for_processing(self) -> bool: - """Polls until the request button is re-enabled, indicating report generation is complete.""" - max_retries = 30 # Approx 2.5 hours total wait time - poll_interval = 300 # 5 minutes between checks - + """ + Polls until the export is actually downloadable. + + The old heuristic asked whether the Request button was enabled again and + treated that as "ready". Oura leaves that button enabled right after it + is clicked, and the generic 'main button' fallback matches almost any + button on the page, so this reported success within seconds and the + caller then failed to find a download button that would not exist for + hours. Poll for the download control itself instead -- it is the only + signal that the archive actually exists. + """ + max_retries = 30 # ~2.5 hours total + poll_interval = 300 # 5 minutes between checks + for i in range(max_retries): - # Check if Request button is enabled again (indicating download is ready) - request_btn = self.page.locator('[data-testid="pageSubtitle"] + button').first - if not await request_btn.is_visible(): - request_btn = self.page.locator('main button').first - - if await request_btn.is_visible() and await request_btn.is_enabled(): - return True # Export is ready - - logger.info(f"Processing... (Attempt {i+1}/{max_retries}) - Next check in {poll_interval}s") + if await self._download_button() is not None: + logger.info("Export is ready for download.") + return True + + logger.info( + f"Export still processing... (Attempt {i+1}/{max_retries}) " + f"- Next check in {poll_interval}s" + ) await self.page.wait_for_timeout(poll_interval * 1000) - await self.page.reload() - await self.page.wait_for_load_state("networkidle") - + try: + await self.page.reload() + await self.page.wait_for_load_state("networkidle", timeout=30000) + except Exception as e: + logger.warning(f"Reload while waiting for export failed: {e}") + + logger.warning("Export did not become downloadable within the wait window.") return False async def _download_file(self, save_dir: str) -> Optional[str]: """Finds the download button and handles the file save dialog.""" - download_btn = self.page.locator("button[aria-label='Download data']").first - try: - await download_btn.wait_for(state="visible", timeout=10000) - except: - pass - - if await download_btn.is_visible(): - logger.info("Download button found. Clicking...") - async with self.page.expect_download() as download_info: - await download_btn.click() - - download = await download_info.value - filename = download.suggested_filename - save_path = os.path.join(save_dir, filename) - await download.save_as(save_path) - logger.info(f"Downloaded to {save_path}") - return save_path - - logger.warning("Download button not found.") - return None + download_btn = await self._download_button() + if download_btn is None: + # Give a slow-rendering page one more chance before giving up. + await self.page.wait_for_timeout(10000) + download_btn = await self._download_button() + + if download_btn is None: + logger.warning("Download button not found.") + return None + + logger.info("Download button found. Clicking...") + # The archive can be large; allow well over Playwright's 30s default. + async with self.page.expect_download(timeout=600000) as download_info: + await download_btn.click() + + download = await download_info.value + filename = download.suggested_filename + save_path = os.path.join(save_dir, filename) + await download.save_as(save_path) + logger.info(f"Downloaded to {save_path}") + return save_path automator = OuraAutomator()