Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions backend/src/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down
174 changes: 126 additions & 48 deletions backend/src/automation.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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()