1437 lines
61 KiB
Python
1437 lines
61 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import logging
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
from datetime import date, datetime, time, timedelta, timezone
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
from zoneinfo import ZoneInfo
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
|
|||
|
|
from config import settings
|
|||
|
|
from paths import DATA_DIR
|
|||
|
|
from services.calendar_models import CalendarMeeting, format_meeting_description
|
|||
|
|
from services.secret_box import decrypt_secret
|
|||
|
|
from services.state_store import (
|
|||
|
|
clear_graph_session,
|
|||
|
|
clear_teams_browser_meta,
|
|||
|
|
get_graph_session,
|
|||
|
|
get_teams_browser_meta,
|
|||
|
|
set_graph_session,
|
|||
|
|
set_teams_browser_meta,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
BROWSER_STATE_FILE = DATA_DIR / "teams_browser_state.json"
|
|||
|
|
MFA_PREVIEW_FILE = DATA_DIR / "teams_mfa_preview.png"
|
|||
|
|
OUTLOOK_CALENDAR_URL = "https://outlook.office.com/calendar/view/workweek"
|
|||
|
|
LOGIN_TIMEOUT_MS = 5 * 60 * 1000
|
|||
|
|
# Number-match / Authenticator MFA can be slow; keep browser open for interaction
|
|||
|
|
MFA_WAIT_MS = 10 * 60 * 1000
|
|||
|
|
# Public Microsoft Office client — used only for optional ROPC (password grant).
|
|||
|
|
# Tenants with MFA / CA usually reject this; then we fall back to browser login.
|
|||
|
|
DEFAULT_PUBLIC_CLIENT_ID = "d3590ed6-52b3-4102-aeff-aad2292ab01c"
|
|||
|
|
GRAPH_SCOPES = "offline_access Calendars.Read User.Read openid profile"
|
|||
|
|
|
|||
|
|
# Extra Chromium flags for containers (no X11 / small /dev/shm)
|
|||
|
|
_DOCKER_CHROME_ARGS = [
|
|||
|
|
"--disable-blink-features=AutomationControlled",
|
|||
|
|
"--no-sandbox",
|
|||
|
|
"--disable-dev-shm-usage",
|
|||
|
|
"--disable-gpu",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _running_in_docker() -> bool:
|
|||
|
|
if os.environ.get("PTS_IN_DOCKER", "").strip().lower() in {"1", "true", "yes"}:
|
|||
|
|
return True
|
|||
|
|
if Path("/.dockerenv").exists():
|
|||
|
|
return True
|
|||
|
|
try:
|
|||
|
|
cgroup = Path("/proc/1/cgroup").read_text(encoding="utf-8", errors="ignore")
|
|||
|
|
if "docker" in cgroup or "containerd" in cgroup or "kubepods" in cgroup:
|
|||
|
|
return True
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _display_available() -> bool:
|
|||
|
|
return bool(os.environ.get("DISPLAY", "").strip())
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _must_run_headless() -> bool:
|
|||
|
|
"""True only when a browser window cannot be shown at all.
|
|||
|
|
|
|||
|
|
Docker with DISPLAY (Xvfb + noVNC) → headed OK, user interacts via :6080.
|
|||
|
|
Linux without DISPLAY/WAYLAND → headless only.
|
|||
|
|
macOS / Windows → headed works without DISPLAY.
|
|||
|
|
"""
|
|||
|
|
if _display_available() or os.environ.get("WAYLAND_DISPLAY", "").strip():
|
|||
|
|
return False
|
|||
|
|
if _running_in_docker():
|
|||
|
|
return True
|
|||
|
|
if sys.platform.startswith("linux"):
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def resolve_playwright_headless(requested: bool | None = None) -> bool:
|
|||
|
|
"""Decide Playwright headless mode.
|
|||
|
|
|
|||
|
|
Preference order:
|
|||
|
|
1. No display possible → always headless
|
|||
|
|
2. Explicit argument (login sends headless=false for MFA)
|
|||
|
|
3. settings.teams_headless
|
|||
|
|
"""
|
|||
|
|
if _must_run_headless():
|
|||
|
|
if requested is False:
|
|||
|
|
logger.warning(
|
|||
|
|
"Headed Playwright requested but no DISPLAY "
|
|||
|
|
"(Docker without Xvfb?) — forcing headless=True."
|
|||
|
|
)
|
|||
|
|
return True
|
|||
|
|
if requested is not None:
|
|||
|
|
return bool(requested)
|
|||
|
|
return bool(settings.teams_headless)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def playwright_launch_args() -> list[str]:
|
|||
|
|
# Always use sandbox-friendly flags in Docker (headed on Xvfb still needs them)
|
|||
|
|
if _running_in_docker() or _must_run_headless():
|
|||
|
|
return list(_DOCKER_CHROME_ARGS)
|
|||
|
|
return ["--disable-blink-features=AutomationControlled"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def browser_view_info() -> dict[str, Any]:
|
|||
|
|
"""How the user can see / interact with the Teams login browser."""
|
|||
|
|
vnc_port = int(os.environ.get("TEAMS_VNC_PORT", "6080") or "6080")
|
|||
|
|
has_display = _display_available()
|
|||
|
|
in_docker = _running_in_docker()
|
|||
|
|
interactive = in_docker and has_display
|
|||
|
|
return {
|
|||
|
|
"in_docker": in_docker,
|
|||
|
|
"display": os.environ.get("DISPLAY") or None,
|
|||
|
|
"interactive": interactive,
|
|||
|
|
"vnc_port": vnc_port,
|
|||
|
|
"mfa_preview": MFA_PREVIEW_FILE.exists(),
|
|||
|
|
"message": (
|
|||
|
|
"Docker:登入時會開啟虛擬 Chrome,請用互動畫面完成 MFA(可點、可輸入)。"
|
|||
|
|
if interactive
|
|||
|
|
else (
|
|||
|
|
"本機:請在跳出的 Chromium 視窗完成 MFA。"
|
|||
|
|
if not in_docker
|
|||
|
|
else "Docker 但無 DISPLAY:無法顯示驗證畫面,請確認容器已啟動 Xvfb。"
|
|||
|
|
)
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TeamsCalendarScraper:
|
|||
|
|
"""Calendar via username/password: Graph API first, browser session as fallback."""
|
|||
|
|
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
self.timezone = ZoneInfo(settings.teams_calendar_timezone)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def enabled(self) -> bool:
|
|||
|
|
return bool(settings.teams_calendar_enabled)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def has_credentials(self) -> bool:
|
|||
|
|
return bool((settings.teams_username or "").strip() and (settings.teams_password or "").strip())
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def has_browser_state(self) -> bool:
|
|||
|
|
return BROWSER_STATE_FILE.exists() and BROWSER_STATE_FILE.stat().st_size > 10
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def has_graph_token(self) -> bool:
|
|||
|
|
"""Unused for fetch path (crawl-only). Kept for status diagnostics."""
|
|||
|
|
session = get_graph_session() or {}
|
|||
|
|
return bool(session.get("access_token") and session.get("refresh_token"))
|
|||
|
|
|
|||
|
|
def status(self) -> dict[str, Any]:
|
|||
|
|
meta = get_teams_browser_meta()
|
|||
|
|
mode = meta.get("auth_mode") or ("browser_session" if self.has_browser_state else None)
|
|||
|
|
last_error = str(meta.get("last_error") or "")
|
|||
|
|
# Only treat as stale for real session-expiry signals (not old Graph errors)
|
|||
|
|
stale = bool(
|
|||
|
|
last_error
|
|||
|
|
and any(
|
|||
|
|
k in last_error.lower()
|
|||
|
|
for k in ("session expired", "re-login", "browser session expired", "session 過期")
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
# Crawl mode: connected = browser cookies present
|
|||
|
|
connected = self.has_browser_state and not stale
|
|||
|
|
return {
|
|||
|
|
"enabled": self.enabled,
|
|||
|
|
"configured": self.has_credentials or self.has_browser_state,
|
|||
|
|
"has_credentials": self.has_credentials,
|
|||
|
|
"connected": connected,
|
|||
|
|
"username": (settings.teams_username or "").strip() or None,
|
|||
|
|
"last_login_at": meta.get("last_login_at"),
|
|||
|
|
"last_error": meta.get("last_error"),
|
|||
|
|
"message": meta.get("message") or "crawl-only (no Graph API)",
|
|||
|
|
"auth_mode": mode,
|
|||
|
|
"source": "teams_browser_crawl",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def format_meeting_description(self, meeting: CalendarMeeting) -> str:
|
|||
|
|
return format_meeting_description(meeting)
|
|||
|
|
|
|||
|
|
def _round_hours(self, hours: float) -> float:
|
|||
|
|
step = settings.hour_step
|
|||
|
|
if hours <= 0:
|
|||
|
|
return 0.0
|
|||
|
|
units = max(1, int(round(hours / step)))
|
|||
|
|
return round(units * step, 1)
|
|||
|
|
|
|||
|
|
def _event_hours(self, start: datetime, end: datetime) -> float:
|
|||
|
|
duration = (end - start).total_seconds() / 3600
|
|||
|
|
return self._round_hours(duration)
|
|||
|
|
|
|||
|
|
def _parse_time(self, value: str | datetime) -> datetime:
|
|||
|
|
if isinstance(value, datetime):
|
|||
|
|
dt = value
|
|||
|
|
else:
|
|||
|
|
cleaned = str(value).replace("Z", "+00:00")
|
|||
|
|
dt = datetime.fromisoformat(cleaned)
|
|||
|
|
if dt.tzinfo is None:
|
|||
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|||
|
|
return dt
|
|||
|
|
|
|||
|
|
def _day_window(self, day: date) -> tuple[datetime, datetime]:
|
|||
|
|
start = datetime.combine(day, time.min, tzinfo=self.timezone)
|
|||
|
|
end = start + timedelta(days=1)
|
|||
|
|
return start, end
|
|||
|
|
|
|||
|
|
def _credentials(self) -> tuple[str, str]:
|
|||
|
|
username = (settings.teams_username or "").strip()
|
|||
|
|
password = decrypt_secret(settings.teams_password or "")
|
|||
|
|
if not username or not password:
|
|||
|
|
raise ValueError("Teams username/password not set. Login on the web UI first.")
|
|||
|
|
return username, password
|
|||
|
|
|
|||
|
|
async def login(
|
|||
|
|
self,
|
|||
|
|
username: str | None = None,
|
|||
|
|
password: str | None = None,
|
|||
|
|
*,
|
|||
|
|
headless: bool | None = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
user = (username or settings.teams_username or "").strip()
|
|||
|
|
pwd = password if password is not None else decrypt_secret(settings.teams_password or "")
|
|||
|
|
if not user or not pwd:
|
|||
|
|
raise ValueError("Teams username and password are required")
|
|||
|
|
|
|||
|
|
# Crawl-only: browser login + save cookies (no Graph API / ROPC)
|
|||
|
|
clear_graph_session()
|
|||
|
|
use_headless = resolve_playwright_headless(headless)
|
|||
|
|
try:
|
|||
|
|
from playwright.async_api import async_playwright
|
|||
|
|
except ImportError as exc:
|
|||
|
|
raise ValueError(
|
|||
|
|
"playwright is not installed. Run: pip install playwright && playwright install chromium"
|
|||
|
|
) from exc
|
|||
|
|
|
|||
|
|
view = browser_view_info()
|
|||
|
|
set_teams_browser_meta(
|
|||
|
|
{
|
|||
|
|
"message": (
|
|||
|
|
f"正在登入 Outlook(headless={use_headless})。"
|
|||
|
|
+ (
|
|||
|
|
f" 請開啟 noVNC 埠 {view['vnc_port']} 完成 MFA;瀏覽器會保持開啟最多 {MFA_WAIT_MS // 60000} 分鐘。"
|
|||
|
|
if not use_headless and _running_in_docker()
|
|||
|
|
else " 請在跳出的視窗完成 MFA。"
|
|||
|
|
if not use_headless
|
|||
|
|
else " 無畫面模式:若卡住請確認 Docker 已啟用 Xvfb/noVNC。"
|
|||
|
|
)
|
|||
|
|
),
|
|||
|
|
"last_error": None,
|
|||
|
|
"mfa_interactive": not use_headless,
|
|||
|
|
"vnc_port": view.get("vnc_port"),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
probe_count = 0
|
|||
|
|
async with async_playwright() as p:
|
|||
|
|
launch_kwargs: dict[str, Any] = {
|
|||
|
|
"headless": use_headless,
|
|||
|
|
"channel": "chromium",
|
|||
|
|
"args": playwright_launch_args(),
|
|||
|
|
}
|
|||
|
|
if _running_in_docker():
|
|||
|
|
launch_kwargs["env"] = {
|
|||
|
|
**os.environ,
|
|||
|
|
"DISPLAY": os.environ.get("DISPLAY", ":99"),
|
|||
|
|
}
|
|||
|
|
browser = await p.chromium.launch(**launch_kwargs)
|
|||
|
|
context = await browser.new_context(
|
|||
|
|
user_agent=(
|
|||
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|||
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|||
|
|
"Chrome/122.0.0.0 Safari/537.36"
|
|||
|
|
),
|
|||
|
|
locale="zh-TW",
|
|||
|
|
timezone_id=settings.teams_calendar_timezone,
|
|||
|
|
viewport={"width": 1280, "height": 800},
|
|||
|
|
)
|
|||
|
|
page = await context.new_page()
|
|||
|
|
try:
|
|||
|
|
await page.goto("https://login.microsoftonline.com/", wait_until="domcontentloaded")
|
|||
|
|
await self._snapshot_mfa(page, "login_start")
|
|||
|
|
await self._fill_microsoft_login(page, user, pwd)
|
|||
|
|
await page.wait_for_timeout(1500)
|
|||
|
|
await self._snapshot_mfa(page, "after_password")
|
|||
|
|
# If MFA / number-match is already on screen, do NOT navigate away
|
|||
|
|
# (would refresh the number). Only open Outlook when not mid-identity.
|
|||
|
|
if not self._on_identity_page(page.url):
|
|||
|
|
try:
|
|||
|
|
await page.goto(
|
|||
|
|
OUTLOOK_CALENDAR_URL,
|
|||
|
|
wait_until="domcontentloaded",
|
|||
|
|
timeout=LOGIN_TIMEOUT_MS,
|
|||
|
|
)
|
|||
|
|
except Exception as nav_exc: # noqa: BLE001
|
|||
|
|
logger.info("Outlook nav after login (MFA may be active): %s", nav_exc)
|
|||
|
|
else:
|
|||
|
|
logger.info("Identity/MFA page active — keeping page open for user: %s", page.url)
|
|||
|
|
await self._wait_for_outlook_ready(page)
|
|||
|
|
# Wait for calendar UI + cookies to settle (browser still open)
|
|||
|
|
await page.wait_for_timeout(5000)
|
|||
|
|
try:
|
|||
|
|
await page.wait_for_load_state("networkidle", timeout=20000)
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# Verify we are not still on login
|
|||
|
|
if "login.microsoftonline.com" in page.url.lower():
|
|||
|
|
await self._snapshot_mfa(page, "still_login")
|
|||
|
|
raise ValueError(
|
|||
|
|
"仍在 Microsoft 登入頁 — 請在互動畫面完成 MFA/密碼後再等一下。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Probe calendar fetch while session is live (same browser)
|
|||
|
|
try:
|
|||
|
|
today = datetime.now(self.timezone).date()
|
|||
|
|
probe_start, _ = self._day_window(today)
|
|||
|
|
_, probe_end = self._day_window(today + timedelta(days=7))
|
|||
|
|
probe_events = await self._page_fetch_calendar_events(page, probe_start, probe_end)
|
|||
|
|
probe_count = len(probe_events)
|
|||
|
|
logger.info("Login probe fetched %s events in live session", probe_count)
|
|||
|
|
except Exception as probe_exc: # noqa: BLE001
|
|||
|
|
logger.warning("Login probe fetch failed (session still saved): %s", probe_exc)
|
|||
|
|
|
|||
|
|
BROWSER_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
await context.storage_state(path=str(BROWSER_STATE_FILE))
|
|||
|
|
await self._snapshot_mfa(page, "success")
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
try:
|
|||
|
|
await self._snapshot_mfa(page, "error")
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
set_teams_browser_meta(
|
|||
|
|
{
|
|||
|
|
"last_error": str(exc),
|
|||
|
|
"message": (
|
|||
|
|
"Teams 登入失敗。請在 noVNC 互動畫面完成 MFA 後再試。"
|
|||
|
|
if _running_in_docker()
|
|||
|
|
else "Teams 登入失敗。請在瀏覽器視窗完成 MFA 後再試。"
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
raise ValueError(f"Teams login failed: {exc}") from exc
|
|||
|
|
finally:
|
|||
|
|
# Close only after success or hard failure — not mid-MFA
|
|||
|
|
await browser.close()
|
|||
|
|
|
|||
|
|
set_teams_browser_meta(
|
|||
|
|
{
|
|||
|
|
"last_login_at": datetime.now(timezone.utc).isoformat(),
|
|||
|
|
"last_error": None,
|
|||
|
|
"message": (
|
|||
|
|
f"Browser crawl session saved. Login probe: {probe_count} events."
|
|||
|
|
if probe_count
|
|||
|
|
else "Browser crawl session saved. If test is 0, re-login and complete MFA fully."
|
|||
|
|
),
|
|||
|
|
"username": user,
|
|||
|
|
"auth_mode": "browser_crawl",
|
|||
|
|
"login_probe_events": probe_count,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return {
|
|||
|
|
"ok": True,
|
|||
|
|
"message": (
|
|||
|
|
f"Teams 瀏覽器登入成功(爬蟲)。即時探測到 {probe_count} 筆會議。"
|
|||
|
|
if probe_count
|
|||
|
|
else "Teams 瀏覽器登入完成。若測試仍 0 筆,請再登入一次並完成 MFA。"
|
|||
|
|
),
|
|||
|
|
"connected": True,
|
|||
|
|
"auth_mode": "browser_crawl",
|
|||
|
|
"login_probe_events": probe_count,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def logout(self) -> None:
|
|||
|
|
if BROWSER_STATE_FILE.exists():
|
|||
|
|
BROWSER_STATE_FILE.unlink()
|
|||
|
|
clear_graph_session()
|
|||
|
|
clear_teams_browser_meta()
|
|||
|
|
|
|||
|
|
def _token_client_id(self) -> str:
|
|||
|
|
return (settings.azure_client_id or "").strip() or DEFAULT_PUBLIC_CLIENT_ID
|
|||
|
|
|
|||
|
|
def _token_tenant(self) -> str:
|
|||
|
|
return (settings.azure_tenant_id or "").strip() or "organizations"
|
|||
|
|
|
|||
|
|
async def _login_graph_ropc(self, username: str, password: str) -> dict[str, Any]:
|
|||
|
|
"""Resource Owner Password Credentials — direct API, no browser."""
|
|||
|
|
url = f"https://login.microsoftonline.com/{self._token_tenant()}/oauth2/v2.0/token"
|
|||
|
|
payload = {
|
|||
|
|
"client_id": self._token_client_id(),
|
|||
|
|
"scope": GRAPH_SCOPES,
|
|||
|
|
"username": username,
|
|||
|
|
"password": password,
|
|||
|
|
"grant_type": "password",
|
|||
|
|
}
|
|||
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|||
|
|
response = await client.post(url, data=payload)
|
|||
|
|
data = response.json() if response.content else {}
|
|||
|
|
if response.status_code >= 400:
|
|||
|
|
err = data.get("error_description") or data.get("error") or response.text
|
|||
|
|
raise ValueError(str(err)[:300])
|
|||
|
|
access = data.get("access_token")
|
|||
|
|
if not access:
|
|||
|
|
raise ValueError("Graph token response missing access_token")
|
|||
|
|
set_graph_session(
|
|||
|
|
{
|
|||
|
|
"access_token": access,
|
|||
|
|
"refresh_token": data.get("refresh_token"),
|
|||
|
|
"scope": data.get("scope"),
|
|||
|
|
"expires_in": data.get("expires_in"),
|
|||
|
|
"token_type": data.get("token_type"),
|
|||
|
|
"auth_mode": "ropc",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
async def _refresh_graph_token(self) -> str:
|
|||
|
|
session = get_graph_session() or {}
|
|||
|
|
refresh = session.get("refresh_token")
|
|||
|
|
if not refresh:
|
|||
|
|
raise ValueError("No Graph refresh_token")
|
|||
|
|
url = f"https://login.microsoftonline.com/{self._token_tenant()}/oauth2/v2.0/token"
|
|||
|
|
payload = {
|
|||
|
|
"client_id": self._token_client_id(),
|
|||
|
|
"scope": GRAPH_SCOPES,
|
|||
|
|
"refresh_token": refresh,
|
|||
|
|
"grant_type": "refresh_token",
|
|||
|
|
}
|
|||
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|||
|
|
response = await client.post(url, data=payload)
|
|||
|
|
data = response.json() if response.content else {}
|
|||
|
|
if response.status_code >= 400:
|
|||
|
|
raise ValueError(data.get("error_description") or data.get("error") or "refresh failed")
|
|||
|
|
access = data.get("access_token")
|
|||
|
|
if not access:
|
|||
|
|
raise ValueError("refresh missing access_token")
|
|||
|
|
session["access_token"] = access
|
|||
|
|
if data.get("refresh_token"):
|
|||
|
|
session["refresh_token"] = data["refresh_token"]
|
|||
|
|
set_graph_session(session)
|
|||
|
|
return access
|
|||
|
|
|
|||
|
|
async def _graph_access_token(self) -> str:
|
|||
|
|
session = get_graph_session() or {}
|
|||
|
|
token = session.get("access_token")
|
|||
|
|
if token:
|
|||
|
|
return str(token)
|
|||
|
|
return await self._refresh_graph_token()
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _on_identity_page(url: str) -> bool:
|
|||
|
|
u = (url or "").lower()
|
|||
|
|
return any(
|
|||
|
|
host in u
|
|||
|
|
for host in (
|
|||
|
|
"login.microsoftonline.com",
|
|||
|
|
"login.live.com",
|
|||
|
|
"login.microsoft.com",
|
|||
|
|
"device.login.microsoftonline.com",
|
|||
|
|
"aadcdn.msauth.net",
|
|||
|
|
"aadcdn.msftauth.net",
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def _snapshot_mfa(self, page: Any, label: str = "") -> None:
|
|||
|
|
"""Write latest login page screenshot for UI polling (Docker MFA)."""
|
|||
|
|
try:
|
|||
|
|
MFA_PREVIEW_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
await page.screenshot(path=str(MFA_PREVIEW_FILE), full_page=False, type="png")
|
|||
|
|
if label:
|
|||
|
|
set_teams_browser_meta(
|
|||
|
|
{
|
|||
|
|
**get_teams_browser_meta(),
|
|||
|
|
"mfa_preview_label": label,
|
|||
|
|
"mfa_preview_at": datetime.now(timezone.utc).isoformat(),
|
|||
|
|
"mfa_page_url": page.url,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
logger.debug("MFA snapshot failed (%s): %s", label, exc)
|
|||
|
|
|
|||
|
|
async def _fill_microsoft_login(self, page: Any, username: str, password: str) -> None:
|
|||
|
|
# Email step
|
|||
|
|
email = page.locator('input[type="email"], input[name="loginfmt"]')
|
|||
|
|
await email.first.wait_for(state="visible", timeout=30000)
|
|||
|
|
await email.first.fill(username)
|
|||
|
|
await self._snapshot_mfa(page, "email_filled")
|
|||
|
|
await page.locator('input[type="submit"], input[data-report-event="Signin_Submit"]').first.click()
|
|||
|
|
|
|||
|
|
# Password step (may be skipped if SSO/MFA device)
|
|||
|
|
try:
|
|||
|
|
pwd = page.locator('input[type="password"], input[name="passwd"]')
|
|||
|
|
await pwd.first.wait_for(state="visible", timeout=15000)
|
|||
|
|
await pwd.first.fill(password)
|
|||
|
|
await self._snapshot_mfa(page, "password_filled")
|
|||
|
|
await page.locator('input[type="submit"], input[data-report-event="Signin_Submit"]').first.click()
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
logger.info("Password field not shown (SSO/MFA path)")
|
|||
|
|
|
|||
|
|
await self._snapshot_mfa(page, "after_credentials")
|
|||
|
|
# Stay signed in? (may appear after MFA — also handled in wait loop)
|
|||
|
|
try:
|
|||
|
|
stay = page.locator('#idSIButton9, input[value="Yes"], input[value="是"]')
|
|||
|
|
await stay.first.wait_for(state="visible", timeout=8000)
|
|||
|
|
await stay.first.click()
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
async def _wait_for_outlook_ready(self, page: Any) -> None:
|
|||
|
|
"""Keep Chromium open while user completes MFA in noVNC / local window."""
|
|||
|
|
deadline = asyncio.get_event_loop().time() + MFA_WAIT_MS / 1000
|
|||
|
|
last_snap = 0.0
|
|||
|
|
while asyncio.get_event_loop().time() < deadline:
|
|||
|
|
url = page.url.lower()
|
|||
|
|
now = asyncio.get_event_loop().time()
|
|||
|
|
# Refresh screenshot ~every 1.5s so UI shows the number / OTP page
|
|||
|
|
if now - last_snap >= 1.5:
|
|||
|
|
await self._snapshot_mfa(page, "mfa_wait")
|
|||
|
|
last_snap = now
|
|||
|
|
remaining = int(deadline - now)
|
|||
|
|
set_teams_browser_meta(
|
|||
|
|
{
|
|||
|
|
**get_teams_browser_meta(),
|
|||
|
|
"message": (
|
|||
|
|
f"等待 MFA/進到 Outlook… 剩餘約 {remaining} 秒。"
|
|||
|
|
" 請在互動畫面操作,不要關閉。"
|
|||
|
|
),
|
|||
|
|
"mfa_page_url": page.url,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if "outlook.office.com" in url and "login" not in url and "login.microsoftonline" not in url:
|
|||
|
|
try:
|
|||
|
|
await page.wait_for_load_state("networkidle", timeout=15000)
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# "Stay signed in?" after MFA
|
|||
|
|
try:
|
|||
|
|
stay = page.locator('#idSIButton9, input[value="Yes"], input[value="是"]')
|
|||
|
|
if await stay.first.is_visible():
|
|||
|
|
await stay.first.click()
|
|||
|
|
await self._snapshot_mfa(page, "stay_signed_in")
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# MFA finished but still not on Outlook — open calendar once
|
|||
|
|
if not self._on_identity_page(page.url) and "outlook.office.com" not in url:
|
|||
|
|
try:
|
|||
|
|
await page.goto(
|
|||
|
|
OUTLOOK_CALENDAR_URL,
|
|||
|
|
wait_until="domcontentloaded",
|
|||
|
|
timeout=60000,
|
|||
|
|
)
|
|||
|
|
await self._snapshot_mfa(page, "nav_outlook")
|
|||
|
|
except Exception as nav_exc: # noqa: BLE001
|
|||
|
|
logger.debug("Deferred Outlook nav: %s", nav_exc)
|
|||
|
|
|
|||
|
|
await asyncio.sleep(0.8)
|
|||
|
|
await self._snapshot_mfa(page, "timeout")
|
|||
|
|
raise TimeoutError(
|
|||
|
|
f"等待 Outlook 逾時({MFA_WAIT_MS // 60000} 分鐘)。"
|
|||
|
|
"請在 noVNC 互動畫面完成 MFA 後再按一次登入。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def fetch_meetings_for_days(self, days: list[date]) -> dict[str, list[CalendarMeeting]]:
|
|||
|
|
if not days:
|
|||
|
|
return {}
|
|||
|
|
if not self.enabled:
|
|||
|
|
return {d.isoformat(): [] for d in days}
|
|||
|
|
|
|||
|
|
# Crawl-only path — never call Graph API (no refresh_token / MFA issues)
|
|||
|
|
clear_graph_session()
|
|||
|
|
|
|||
|
|
if not self.has_browser_state:
|
|||
|
|
if self.has_credentials:
|
|||
|
|
# Docker has no X server — never force headed mode
|
|||
|
|
await self.login(headless=resolve_playwright_headless(None))
|
|||
|
|
else:
|
|||
|
|
raise ValueError("Teams 未登入。請到設定頁登入 Teams(瀏覽器 / MFA)。")
|
|||
|
|
|
|||
|
|
sorted_days = sorted(set(days))
|
|||
|
|
range_start, _ = self._day_window(sorted_days[0])
|
|||
|
|
_, range_end = self._day_window(sorted_days[-1])
|
|||
|
|
|
|||
|
|
errors: list[str] = []
|
|||
|
|
raw_events: list[dict[str, Any]] = []
|
|||
|
|
session_expired = False
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
raw_events = await self._scrape_calendar_events(range_start, range_end)
|
|||
|
|
logger.info("Browser crawl returned %s raw events", len(raw_events))
|
|||
|
|
except Exception as first_exc: # noqa: BLE001
|
|||
|
|
msg = f"Browser crawl: {first_exc}"
|
|||
|
|
errors.append(msg)
|
|||
|
|
logger.warning("Teams crawl failed: %s", first_exc)
|
|||
|
|
err_l = str(first_exc).lower()
|
|||
|
|
session_expired = "expired" in err_l or "login" in err_l or "re-login" in err_l
|
|||
|
|
if session_expired and BROWSER_STATE_FILE.exists():
|
|||
|
|
try:
|
|||
|
|
BROWSER_STATE_FILE.unlink()
|
|||
|
|
logger.info("Cleared expired Teams browser state")
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# Do NOT auto re-login during fetch (MFA needs human). Ask user to login in Settings.
|
|||
|
|
if not raw_events and (session_expired or not self.has_browser_state):
|
|||
|
|
raise ValueError(
|
|||
|
|
"Teams 網頁 session 無效或過期。請到設定頁重新「登入 Teams」並完成 MFA,"
|
|||
|
|
"再按「測試抓會議」。"
|
|||
|
|
+ (" 詳情: " + " | ".join(errors) if errors else "")
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
meetings_by_day: dict[str, list[CalendarMeeting]] = {d.isoformat(): [] for d in sorted_days}
|
|||
|
|
allowed = set(meetings_by_day.keys())
|
|||
|
|
seen_meetings: set[tuple[str, str, str]] = set()
|
|||
|
|
|
|||
|
|
for item in raw_events:
|
|||
|
|
try:
|
|||
|
|
start = self._parse_time(item["start"])
|
|||
|
|
end = self._parse_time(item["end"])
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
continue
|
|||
|
|
if end <= start:
|
|||
|
|
continue
|
|||
|
|
if item.get("is_cancelled"):
|
|||
|
|
continue
|
|||
|
|
show_as = str(item.get("show_as") or "").lower()
|
|||
|
|
# free / workingelsewhere often still real meetings in some tenants — only drop free
|
|||
|
|
if show_as == "free":
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
local_day = start.astimezone(self.timezone).date().isoformat()
|
|||
|
|
if local_day not in allowed:
|
|||
|
|
continue
|
|||
|
|
hours = self._event_hours(start, end)
|
|||
|
|
if hours <= 0:
|
|||
|
|
continue
|
|||
|
|
subject = str(item.get("subject") or "Meeting").strip()
|
|||
|
|
dedupe_key = (
|
|||
|
|
subject.lower(),
|
|||
|
|
start.astimezone(timezone.utc).isoformat(),
|
|||
|
|
end.astimezone(timezone.utc).isoformat(),
|
|||
|
|
)
|
|||
|
|
if dedupe_key in seen_meetings:
|
|||
|
|
continue
|
|||
|
|
seen_meetings.add(dedupe_key)
|
|||
|
|
meetings_by_day[local_day].append(
|
|||
|
|
CalendarMeeting(
|
|||
|
|
subject=subject,
|
|||
|
|
start=start,
|
|||
|
|
end=end,
|
|||
|
|
hours=hours,
|
|||
|
|
is_online=bool(item.get("is_online")),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if not any(meetings_by_day.values()) and errors:
|
|||
|
|
set_teams_browser_meta(
|
|||
|
|
{
|
|||
|
|
**get_teams_browser_meta(),
|
|||
|
|
"last_error": " | ".join(errors)[:500],
|
|||
|
|
"message": "會議同步回傳 0 筆,可能是 session 過期或 token 無效",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return meetings_by_day
|
|||
|
|
|
|||
|
|
async def list_meetings(
|
|||
|
|
self,
|
|||
|
|
start: date,
|
|||
|
|
end: date,
|
|||
|
|
*,
|
|||
|
|
skip_holidays: bool = True,
|
|||
|
|
debug: bool = False,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""Public payload for the web UI: meetings by day + totals.
|
|||
|
|
|
|||
|
|
When skip_holidays=True (default), only Taiwan workdays are included
|
|||
|
|
(weekends + TW public holidays skipped), matching PTS fill range behavior.
|
|||
|
|
"""
|
|||
|
|
from services.holidays_util import is_workday, iter_workdays
|
|||
|
|
|
|||
|
|
if end < start:
|
|||
|
|
start, end = end, start
|
|||
|
|
|
|||
|
|
if skip_holidays:
|
|||
|
|
days = iter_workdays(start, end)
|
|||
|
|
calendar_span = (end - start).days + 1
|
|||
|
|
skipped_days = max(0, calendar_span - len(days))
|
|||
|
|
else:
|
|||
|
|
days = []
|
|||
|
|
cursor = start
|
|||
|
|
while cursor <= end:
|
|||
|
|
days.append(cursor)
|
|||
|
|
cursor += timedelta(days=1)
|
|||
|
|
skipped_days = 0
|
|||
|
|
|
|||
|
|
debug_info: dict[str, Any] = {
|
|||
|
|
"mode": "browser_crawl_only",
|
|||
|
|
"graph_api": "disabled",
|
|||
|
|
"has_browser_state": self.has_browser_state,
|
|||
|
|
"has_credentials": self.has_credentials,
|
|||
|
|
"enabled": self.enabled,
|
|||
|
|
"day_count": len(days),
|
|||
|
|
"days": [d.isoformat() for d in days],
|
|||
|
|
"steps": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
meetings_by_day: dict[str, list[CalendarMeeting]] = {}
|
|||
|
|
fetch_error: str | None = None
|
|||
|
|
try:
|
|||
|
|
if days:
|
|||
|
|
meetings_by_day = await self.fetch_meetings_for_days(days)
|
|||
|
|
debug_info["steps"].append("fetch_meetings_for_days: ok")
|
|||
|
|
else:
|
|||
|
|
debug_info["steps"].append("no days after holiday filter")
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
fetch_error = str(exc)
|
|||
|
|
debug_info["steps"].append(f"fetch_meetings_for_days: ERROR {exc}")
|
|||
|
|
logger.exception("list_meetings fetch failed")
|
|||
|
|
|
|||
|
|
days_out: list[dict[str, Any]] = []
|
|||
|
|
total_hours = 0.0
|
|||
|
|
total_count = 0
|
|||
|
|
|
|||
|
|
for day in days:
|
|||
|
|
key = day.isoformat()
|
|||
|
|
items = meetings_by_day.get(key, [])
|
|||
|
|
day_hours = round(sum(m.hours for m in items), 1)
|
|||
|
|
total_hours += day_hours
|
|||
|
|
total_count += len(items)
|
|||
|
|
days_out.append(
|
|||
|
|
{
|
|||
|
|
"date": key,
|
|||
|
|
"hours": day_hours,
|
|||
|
|
"count": len(items),
|
|||
|
|
"is_workday": is_workday(day),
|
|||
|
|
"meetings": [
|
|||
|
|
{
|
|||
|
|
"subject": m.subject,
|
|||
|
|
"start": m.start.astimezone(self.timezone).isoformat(),
|
|||
|
|
"end": m.end.astimezone(self.timezone).isoformat(),
|
|||
|
|
"hours": m.hours,
|
|||
|
|
"is_online": m.is_online,
|
|||
|
|
"time_label": (
|
|||
|
|
f"{m.start.astimezone(self.timezone).strftime('%H:%M')}"
|
|||
|
|
f"–{m.end.astimezone(self.timezone).strftime('%H:%M')}"
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
for m in items
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
meta = get_teams_browser_meta()
|
|||
|
|
payload: dict[str, Any] = {
|
|||
|
|
"ok": fetch_error is None,
|
|||
|
|
"enabled": self.enabled,
|
|||
|
|
"connected": self.status().get("connected"),
|
|||
|
|
"timezone": settings.teams_calendar_timezone,
|
|||
|
|
"start_date": start.isoformat(),
|
|||
|
|
"end_date": end.isoformat(),
|
|||
|
|
"skip_holidays": skip_holidays,
|
|||
|
|
"workday_count": len(days),
|
|||
|
|
"skipped_days": skipped_days,
|
|||
|
|
"total_hours": round(total_hours, 1),
|
|||
|
|
"total_count": total_count,
|
|||
|
|
"days": days_out,
|
|||
|
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
|||
|
|
"error": fetch_error,
|
|||
|
|
"status": self.status(),
|
|||
|
|
"fetch_hint": (
|
|||
|
|
None
|
|||
|
|
if total_count
|
|||
|
|
else (
|
|||
|
|
fetch_error
|
|||
|
|
or meta.get("last_error")
|
|||
|
|
or meta.get("message")
|
|||
|
|
or "若一直是 0 筆:請先「登入 Teams」完成 MFA,再按測試。"
|
|||
|
|
)
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
if debug:
|
|||
|
|
payload["debug"] = debug_info
|
|||
|
|
return payload
|
|||
|
|
|
|||
|
|
async def _scrape_calendar_events(
|
|||
|
|
self,
|
|||
|
|
range_start: datetime,
|
|||
|
|
range_end: datetime,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
try:
|
|||
|
|
from playwright.async_api import async_playwright
|
|||
|
|
except ImportError as exc:
|
|||
|
|
raise ValueError("playwright is not installed") from exc
|
|||
|
|
|
|||
|
|
if not self.has_browser_state:
|
|||
|
|
raise ValueError("No Teams browser session — please login on Settings page")
|
|||
|
|
|
|||
|
|
collected: list[dict[str, Any]] = []
|
|||
|
|
graph_payloads: list[dict[str, Any]] = []
|
|||
|
|
captured_bearers: list[str] = []
|
|||
|
|
capture_errors: list[str] = []
|
|||
|
|
seen_urls: list[str] = []
|
|||
|
|
|
|||
|
|
async with async_playwright() as p:
|
|||
|
|
# channel="chromium" forces Chrome's "new" headless mode (same full
|
|||
|
|
# browser binary as headed, just no window) instead of Playwright's
|
|||
|
|
# lightweight headless-shell. The shell's fingerprint (GPU/canvas,
|
|||
|
|
# navigator.plugins, missing APIs, etc.) diverges enough from a real
|
|||
|
|
# headed browser that Azure AD's sign-in risk detection was bouncing
|
|||
|
|
# our cookie-reuse requests back to the login page — which we then
|
|||
|
|
# (wrongly) treated as an expired session, deleting the saved cookies
|
|||
|
|
# and forcing a fresh interactive MFA login on every single fetch.
|
|||
|
|
browser = await p.chromium.launch(
|
|||
|
|
headless=resolve_playwright_headless(True),
|
|||
|
|
channel="chromium",
|
|||
|
|
args=playwright_launch_args(),
|
|||
|
|
)
|
|||
|
|
context = await browser.new_context(
|
|||
|
|
storage_state=str(BROWSER_STATE_FILE),
|
|||
|
|
user_agent=(
|
|||
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|||
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|||
|
|
"Chrome/122.0.0.0 Safari/537.36"
|
|||
|
|
),
|
|||
|
|
locale="zh-TW",
|
|||
|
|
timezone_id=settings.teams_calendar_timezone,
|
|||
|
|
)
|
|||
|
|
page = await context.new_page()
|
|||
|
|
|
|||
|
|
def on_request(request: Any) -> None:
|
|||
|
|
try:
|
|||
|
|
auth = (request.headers or {}).get("authorization") or ""
|
|||
|
|
if auth.lower().startswith("bearer ") and len(auth) > 40:
|
|||
|
|
token = auth.split(" ", 1)[1].strip()
|
|||
|
|
if token not in captured_bearers:
|
|||
|
|
captured_bearers.append(token)
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
async def on_response(response: Any) -> None:
|
|||
|
|
try:
|
|||
|
|
if response.status != 200:
|
|||
|
|
return
|
|||
|
|
url = response.url
|
|||
|
|
url_l = url.lower()
|
|||
|
|
if len(seen_urls) < 80:
|
|||
|
|
seen_urls.append(url[:180])
|
|||
|
|
interesting = any(
|
|||
|
|
key in url_l
|
|||
|
|
for key in (
|
|||
|
|
"calendarview",
|
|||
|
|
"calendar/getschedule",
|
|||
|
|
"calendarservice",
|
|||
|
|
"/me/events",
|
|||
|
|
"finditem",
|
|||
|
|
"getcalendarview",
|
|||
|
|
"service.svc",
|
|||
|
|
"outlook.office.com/api/",
|
|||
|
|
"graph.microsoft.com",
|
|||
|
|
"substrate.office.com",
|
|||
|
|
"calendar",
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
if not interesting:
|
|||
|
|
return
|
|||
|
|
ctype = (response.headers.get("content-type") or "").lower()
|
|||
|
|
if "json" not in ctype and "javascript" not in ctype and "text" not in ctype:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
data = await response.json()
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
return
|
|||
|
|
if isinstance(data, dict):
|
|||
|
|
graph_payloads.append(data)
|
|||
|
|
elif isinstance(data, list):
|
|||
|
|
graph_payloads.append({"value": data})
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
page.on("request", on_request)
|
|||
|
|
page.on("response", on_response)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Load calendar SPA
|
|||
|
|
date_param = range_start.date().isoformat()
|
|||
|
|
urls_to_try = [
|
|||
|
|
f"https://outlook.office.com/calendar/view/week?startdt={date_param}",
|
|||
|
|
OUTLOOK_CALENDAR_URL,
|
|||
|
|
"https://outlook.office.com/calendar/view/month",
|
|||
|
|
"https://outlook.office.com/mail/",
|
|||
|
|
]
|
|||
|
|
landed = False
|
|||
|
|
for cal_url in urls_to_try:
|
|||
|
|
try:
|
|||
|
|
await page.goto(cal_url, wait_until="domcontentloaded", timeout=90000)
|
|||
|
|
await page.wait_for_timeout(3000)
|
|||
|
|
url_now = page.url.lower()
|
|||
|
|
if "login.microsoftonline.com" in url_now or "login.live.com" in url_now:
|
|||
|
|
if BROWSER_STATE_FILE.exists():
|
|||
|
|
try:
|
|||
|
|
BROWSER_STATE_FILE.unlink()
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
set_teams_browser_meta(
|
|||
|
|
{
|
|||
|
|
**get_teams_browser_meta(),
|
|||
|
|
"last_error": "session expired",
|
|||
|
|
"message": "Browser session expired. Re-login Teams in Settings (complete MFA).",
|
|||
|
|
"auth_mode": "browser_crawl",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
raise ValueError(
|
|||
|
|
"Teams browser session expired — open Settings → login Teams (complete MFA)."
|
|||
|
|
)
|
|||
|
|
landed = True
|
|||
|
|
if "calendar" in url_now or "outlook.office.com" in url_now:
|
|||
|
|
break
|
|||
|
|
except ValueError:
|
|||
|
|
raise
|
|||
|
|
except Exception as nav_exc: # noqa: BLE001
|
|||
|
|
capture_errors.append(f"nav {cal_url}: {nav_exc}")
|
|||
|
|
if not landed:
|
|||
|
|
raise ValueError("Could not open Outlook with saved session")
|
|||
|
|
|
|||
|
|
# Wait for SPA network
|
|||
|
|
try:
|
|||
|
|
await page.wait_for_load_state("networkidle", timeout=20000)
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
await page.wait_for_timeout(5000)
|
|||
|
|
|
|||
|
|
# A) Page-context fetch (cookies + SPA origin) — most reliable crawl
|
|||
|
|
try:
|
|||
|
|
page_events = await self._page_fetch_calendar_events(page, range_start, range_end)
|
|||
|
|
if page_events:
|
|||
|
|
collected = page_events
|
|||
|
|
logger.info("Page-context fetch returned %s events", len(collected))
|
|||
|
|
except Exception as pe: # noqa: BLE001
|
|||
|
|
capture_errors.append(f"page-fetch: {pe}")
|
|||
|
|
logger.info("Page-context fetch failed: %s", pe)
|
|||
|
|
|
|||
|
|
# B) Use bearer tokens the SPA itself sent (not Graph app registration)
|
|||
|
|
if not collected and captured_bearers:
|
|||
|
|
for token in captured_bearers[:3]:
|
|||
|
|
try:
|
|||
|
|
events = await self._fetch_graph_calendar_view(
|
|||
|
|
context, token, range_start, range_end
|
|||
|
|
)
|
|||
|
|
if events:
|
|||
|
|
collected = events
|
|||
|
|
logger.info(
|
|||
|
|
"SPA-intercepted bearer returned %s events", len(collected)
|
|||
|
|
)
|
|||
|
|
break
|
|||
|
|
except Exception as te: # noqa: BLE001
|
|||
|
|
capture_errors.append(f"bearer: {te}")
|
|||
|
|
|
|||
|
|
# C) Parse any intercepted JSON payloads. A single page view only
|
|||
|
|
# shows "this week" though — to cover a wider requested range
|
|||
|
|
# (e.g. the past month) we must page through every week in
|
|||
|
|
# [range_start, range_end] so each one fires its own calendarView
|
|||
|
|
# XHR that on_response() can capture.
|
|||
|
|
if not collected:
|
|||
|
|
await self._page_through_weeks(page, range_start, range_end, capture_errors)
|
|||
|
|
collected = self._events_from_graph_payloads(graph_payloads)
|
|||
|
|
if collected:
|
|||
|
|
logger.info("XHR crawl intercept returned %s events", len(collected))
|
|||
|
|
else:
|
|||
|
|
capture_errors.append(
|
|||
|
|
f"XHR payloads={len(graph_payloads)} bearers={len(captured_bearers)}"
|
|||
|
|
)
|
|||
|
|
logger.info(
|
|||
|
|
"No calendar events. sample_urls=%s",
|
|||
|
|
seen_urls[:15],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
await context.storage_state(path=str(BROWSER_STATE_FILE))
|
|||
|
|
finally:
|
|||
|
|
await browser.close()
|
|||
|
|
|
|||
|
|
if not collected and capture_errors:
|
|||
|
|
logger.warning("Teams crawl empty. errors=%s", capture_errors)
|
|||
|
|
return collected
|
|||
|
|
|
|||
|
|
async def _jump_to_date(self, page: Any, target: date) -> bool:
|
|||
|
|
"""Click the mini date-picker's day cell to move the main calendar view.
|
|||
|
|
|
|||
|
|
Reloading the page with a `?startdt=` query param does NOT work — OWA
|
|||
|
|
ignores it on a fresh navigation and always renders "today"'s week. The
|
|||
|
|
mini date-picker (the small monthly calendar in the folder pane) is the
|
|||
|
|
only reliable way to jump the main view to an arbitrary date without a
|
|||
|
|
full reload. Its day buttons are labelled e.g. "1, 7 月, 2026" (day,
|
|||
|
|
month, year) in zh-TW. If the target date's month isn't currently
|
|||
|
|
rendered in the picker, we click "移至上個月" (previous month) to page
|
|||
|
|
the mini-calendar itself back until the day becomes visible.
|
|||
|
|
"""
|
|||
|
|
label = f"{target.day}, {target.month} 月, {target.year}"
|
|||
|
|
day_button = page.locator(f'button[aria-label="{label}"]')
|
|||
|
|
prev_month_button = page.locator('button[aria-label^="移至上個月"]')
|
|||
|
|
for _ in range(36): # safety cap: ~3 years of previous-month clicks
|
|||
|
|
try:
|
|||
|
|
if await day_button.count() > 0:
|
|||
|
|
await day_button.first.click(timeout=5000)
|
|||
|
|
return True
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
try:
|
|||
|
|
if await prev_month_button.count() == 0:
|
|||
|
|
return False
|
|||
|
|
await prev_month_button.first.click(timeout=5000)
|
|||
|
|
await page.wait_for_timeout(400)
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
return False
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
async def _page_through_weeks(
|
|||
|
|
self,
|
|||
|
|
page: Any,
|
|||
|
|
range_start: datetime,
|
|||
|
|
range_end: datetime,
|
|||
|
|
capture_errors: list[str],
|
|||
|
|
) -> None:
|
|||
|
|
"""Walk the Outlook SPA's own calendar view across the requested range.
|
|||
|
|
|
|||
|
|
The XHR-sniffing fallback (path C) only sees whatever calendar view is
|
|||
|
|
currently on screen. Without this, a request for "the past month" would
|
|||
|
|
silently only ever capture the current week. We click through the mini
|
|||
|
|
date-picker to each week in the range so every jump fires a fresh
|
|||
|
|
calendarView call for that week, which on_response() captures into
|
|||
|
|
graph_payloads — accumulated across every iteration.
|
|||
|
|
"""
|
|||
|
|
cursor = range_start.date()
|
|||
|
|
end_date = range_end.date()
|
|||
|
|
max_weeks = 14 # safety cap (~3 months) so a huge range can't hang forever
|
|||
|
|
visited = 0
|
|||
|
|
while cursor <= end_date and visited < max_weeks:
|
|||
|
|
jumped = False
|
|||
|
|
try:
|
|||
|
|
jumped = await self._jump_to_date(page, cursor)
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
capture_errors.append(f"date-picker-nav {cursor.isoformat()}: {exc}")
|
|||
|
|
if not jumped:
|
|||
|
|
# Fall back to a full reload — slower and often ignored by OWA,
|
|||
|
|
# but better than skipping the week entirely.
|
|||
|
|
try:
|
|||
|
|
week_url = (
|
|||
|
|
"https://outlook.office.com/calendar/view/week"
|
|||
|
|
f"?startdt={cursor.isoformat()}"
|
|||
|
|
)
|
|||
|
|
await page.goto(week_url, wait_until="domcontentloaded", timeout=60000)
|
|||
|
|
await page.wait_for_timeout(3000)
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
capture_errors.append(f"week-nav {cursor.isoformat()}: {exc}")
|
|||
|
|
try:
|
|||
|
|
await page.wait_for_load_state("networkidle", timeout=15000)
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
await page.wait_for_timeout(3500)
|
|||
|
|
# Nudge the SPA so it actually re-issues the calendarView XHR for
|
|||
|
|
# this specific week (some builds cache/skip the fetch otherwise).
|
|||
|
|
try:
|
|||
|
|
await page.keyboard.press("ArrowRight")
|
|||
|
|
await page.wait_for_timeout(1000)
|
|||
|
|
await page.keyboard.press("ArrowLeft")
|
|||
|
|
await page.wait_for_timeout(1500)
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
cursor += timedelta(days=7)
|
|||
|
|
visited += 1
|
|||
|
|
logger.info(
|
|||
|
|
"Paged through %s week(s) covering %s..%s",
|
|||
|
|
visited,
|
|||
|
|
range_start.date().isoformat(),
|
|||
|
|
range_end.date().isoformat(),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def _page_fetch_calendar_events(
|
|||
|
|
self,
|
|||
|
|
page: Any,
|
|||
|
|
range_start: datetime,
|
|||
|
|
range_end: datetime,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""Run fetch() inside the Outlook page (full cookie + same-site context)."""
|
|||
|
|
start_s = range_start.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|||
|
|
end_s = range_end.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|||
|
|
tz = settings.teams_calendar_timezone
|
|||
|
|
result = await page.evaluate(
|
|||
|
|
"""async ({ startS, endS, tz }) => {
|
|||
|
|
const urls = [
|
|||
|
|
`https://outlook.office.com/api/v2.0/me/calendarview?startDateTime=${encodeURIComponent(startS)}&endDateTime=${encodeURIComponent(endS)}&$top=200&$orderby=Start/DateTime`,
|
|||
|
|
`https://outlook.office.com/api/v2.0/me/calendar/calendarView?startDateTime=${encodeURIComponent(startS)}&endDateTime=${encodeURIComponent(endS)}&$top=200`,
|
|||
|
|
`https://graph.microsoft.com/v1.0/me/calendarView?startDateTime=${encodeURIComponent(startS)}&endDateTime=${encodeURIComponent(endS)}&$top=200&$orderby=start/dateTime`,
|
|||
|
|
`https://outlook.office.com/api/v2.0/me/events?$top=100&$orderby=Start/DateTime&$filter=Start/DateTime ge '${startS}'`,
|
|||
|
|
];
|
|||
|
|
const out = [];
|
|||
|
|
for (const url of urls) {
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(url, {
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: {
|
|||
|
|
'Accept': 'application/json',
|
|||
|
|
'Prefer': `outlook.timezone="${tz}"`,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const text = await r.text();
|
|||
|
|
let body = null;
|
|||
|
|
try { body = JSON.parse(text); } catch (e) { body = text.slice(0, 200); }
|
|||
|
|
out.push({ url, status: r.status, body });
|
|||
|
|
if (r.status === 200 && body && (body.value || body.Value)) {
|
|||
|
|
return { ok: true, status: r.status, url, body };
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
out.push({ url, error: String(e) });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { ok: false, attempts: out };
|
|||
|
|
}""",
|
|||
|
|
{"startS": start_s, "endS": end_s, "tz": tz},
|
|||
|
|
)
|
|||
|
|
if not isinstance(result, dict):
|
|||
|
|
return []
|
|||
|
|
if result.get("ok") and isinstance(result.get("body"), dict):
|
|||
|
|
events = self._events_from_graph_payloads([result["body"]])
|
|||
|
|
logger.info("page fetch ok via %s → %s events", result.get("url"), len(events))
|
|||
|
|
return events
|
|||
|
|
# log attempts briefly
|
|||
|
|
attempts = result.get("attempts") or []
|
|||
|
|
for a in attempts[:6]:
|
|||
|
|
logger.info(
|
|||
|
|
"page-fetch attempt status=%s url=%s err=%s",
|
|||
|
|
a.get("status"),
|
|||
|
|
str(a.get("url") or "")[:80],
|
|||
|
|
a.get("error"),
|
|||
|
|
)
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
async def _extract_access_token(self, page: Any) -> str | None:
|
|||
|
|
"""Pull MSAL / ADAL access tokens from browser storage."""
|
|||
|
|
try:
|
|||
|
|
token = await page.evaluate(
|
|||
|
|
"""() => {
|
|||
|
|
function looksLikeJwt(s) {
|
|||
|
|
return typeof s === 'string' && s.split('.').length === 3 && s.length > 40;
|
|||
|
|
}
|
|||
|
|
function walk(obj, depth) {
|
|||
|
|
if (!obj || depth > 6) return null;
|
|||
|
|
if (typeof obj === 'string') {
|
|||
|
|
if (looksLikeJwt(obj)) return obj;
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
if (typeof obj !== 'object') return null;
|
|||
|
|
// MSAL credential entry
|
|||
|
|
if (obj.secret && looksLikeJwt(obj.secret)) {
|
|||
|
|
const ct = String(obj.credentialType || obj.credential_type || '').toLowerCase();
|
|||
|
|
if (!ct || ct.includes('access')) return obj.secret;
|
|||
|
|
}
|
|||
|
|
if (obj.access_token && looksLikeJwt(obj.access_token)) return obj.access_token;
|
|||
|
|
if (obj.accessToken && looksLikeJwt(obj.accessToken)) return obj.accessToken;
|
|||
|
|
for (const v of Object.values(obj)) {
|
|||
|
|
const found = walk(v, depth + 1);
|
|||
|
|
if (found) return found;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const stores = [localStorage, sessionStorage];
|
|||
|
|
for (const store of stores) {
|
|||
|
|
for (let i = 0; i < store.length; i++) {
|
|||
|
|
const k = store.key(i);
|
|||
|
|
if (!k) continue;
|
|||
|
|
const raw = store.getItem(k);
|
|||
|
|
if (!raw || raw.length < 30) continue;
|
|||
|
|
// quick skip
|
|||
|
|
if (!/access|msal|token|login/i.test(k + raw.slice(0, 80))) {
|
|||
|
|
if (!raw.includes('eyJ')) continue;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const data = JSON.parse(raw);
|
|||
|
|
const found = walk(data, 0);
|
|||
|
|
if (found) return found;
|
|||
|
|
} catch (e) {
|
|||
|
|
if (looksLikeJwt(raw)) return raw;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}"""
|
|||
|
|
)
|
|||
|
|
return token if isinstance(token, str) and len(token) > 40 else None
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
logger.info("Token extract failed: %s", exc)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def _fetch_outlook_rest_calendar_view(
|
|||
|
|
self,
|
|||
|
|
context: Any,
|
|||
|
|
range_start: datetime,
|
|||
|
|
range_end: datetime,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""Outlook REST v2 using OWA cookies (no Graph app registration)."""
|
|||
|
|
from urllib.parse import quote
|
|||
|
|
|
|||
|
|
start_s = range_start.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|||
|
|
end_s = range_end.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|||
|
|
urls = [
|
|||
|
|
(
|
|||
|
|
"https://outlook.office.com/api/v2.0/me/calendarview"
|
|||
|
|
f"?startDateTime={quote(start_s)}&endDateTime={quote(end_s)}"
|
|||
|
|
"&$top=200&$orderby=Start/DateTime"
|
|||
|
|
"&$select=Subject,Start,End,IsCancelled,IsOnlineMeeting,ShowAs"
|
|||
|
|
),
|
|||
|
|
(
|
|||
|
|
"https://outlook.office365.com/api/v2.0/me/calendarview"
|
|||
|
|
f"?startDateTime={quote(start_s)}&endDateTime={quote(end_s)}"
|
|||
|
|
"&$top=200&$orderby=Start/DateTime"
|
|||
|
|
),
|
|||
|
|
]
|
|||
|
|
headers = {
|
|||
|
|
"Accept": "application/json",
|
|||
|
|
"Prefer": f'outlook.timezone="{settings.teams_calendar_timezone}"',
|
|||
|
|
}
|
|||
|
|
for url in urls:
|
|||
|
|
response = await context.request.get(url, headers=headers)
|
|||
|
|
if response.status == 401 or response.status == 403:
|
|||
|
|
logger.info("Outlook REST %s → %s", url.split("?")[0], response.status)
|
|||
|
|
continue
|
|||
|
|
if response.status != 200:
|
|||
|
|
logger.info("Outlook REST failed %s: %s", response.status, (await response.text())[:160])
|
|||
|
|
continue
|
|||
|
|
data = await response.json()
|
|||
|
|
events = self._events_from_graph_payloads([data])
|
|||
|
|
if events:
|
|||
|
|
return events
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
async def _fetch_graph_calendar_view(
|
|||
|
|
self,
|
|||
|
|
context: Any,
|
|||
|
|
token: str,
|
|||
|
|
range_start: datetime,
|
|||
|
|
range_end: datetime,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
from urllib.parse import quote
|
|||
|
|
|
|||
|
|
start_s = range_start.isoformat()
|
|||
|
|
end_s = range_end.isoformat()
|
|||
|
|
url = (
|
|||
|
|
"https://graph.microsoft.com/v1.0/me/calendarView"
|
|||
|
|
f"?startDateTime={quote(start_s)}"
|
|||
|
|
f"&endDateTime={quote(end_s)}"
|
|||
|
|
"&$select=subject,start,end,isCancelled,isOnlineMeeting,showAs"
|
|||
|
|
"&$orderby=start/dateTime"
|
|||
|
|
"&$top=200"
|
|||
|
|
)
|
|||
|
|
response = await context.request.get(
|
|||
|
|
url,
|
|||
|
|
headers={
|
|||
|
|
"Authorization": f"Bearer {token}",
|
|||
|
|
"Prefer": f'outlook.timezone="{settings.teams_calendar_timezone}"',
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
if response.status != 200:
|
|||
|
|
body = ""
|
|||
|
|
try:
|
|||
|
|
body = (await response.text())[:200]
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
logger.info("Graph calendarView via browser token failed: %s %s", response.status, body)
|
|||
|
|
return []
|
|||
|
|
data = await response.json()
|
|||
|
|
return self._events_from_graph_payloads([data])
|
|||
|
|
|
|||
|
|
async def _fetch_via_graph_api(
|
|||
|
|
self,
|
|||
|
|
range_start: datetime,
|
|||
|
|
range_end: datetime,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""Graph calendarView using stored access token (httpx)."""
|
|||
|
|
from urllib.parse import quote
|
|||
|
|
|
|||
|
|
token = await self._graph_access_token()
|
|||
|
|
start_s = range_start.isoformat()
|
|||
|
|
end_s = range_end.isoformat()
|
|||
|
|
url = (
|
|||
|
|
"https://graph.microsoft.com/v1.0/me/calendarView"
|
|||
|
|
f"?startDateTime={quote(start_s)}"
|
|||
|
|
f"&endDateTime={quote(end_s)}"
|
|||
|
|
"&$select=subject,start,end,isCancelled,isOnlineMeeting,showAs"
|
|||
|
|
"&$orderby=start/dateTime"
|
|||
|
|
"&$top=200"
|
|||
|
|
)
|
|||
|
|
headers = {
|
|||
|
|
"Authorization": f"Bearer {token}",
|
|||
|
|
"Prefer": f'outlook.timezone="{settings.teams_calendar_timezone}"',
|
|||
|
|
}
|
|||
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|||
|
|
response = await client.get(url, headers=headers)
|
|||
|
|
if response.status_code == 401:
|
|||
|
|
# try refresh then once more
|
|||
|
|
try:
|
|||
|
|
token = await self._refresh_graph_token()
|
|||
|
|
headers["Authorization"] = f"Bearer {token}"
|
|||
|
|
response = await client.get(url, headers=headers)
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
raise ValueError(f"Graph token expired and refresh failed: {exc}") from exc
|
|||
|
|
if response.status_code >= 400:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"Graph calendarView HTTP {response.status_code}: {response.text[:200]}"
|
|||
|
|
)
|
|||
|
|
data = response.json()
|
|||
|
|
return self._events_from_graph_payloads([data])
|
|||
|
|
|
|||
|
|
def _events_from_graph_payloads(self, payloads: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
|
|
events: list[dict[str, Any]] = []
|
|||
|
|
seen: set[tuple[str, str, str]] = set()
|
|||
|
|
for payload in payloads:
|
|||
|
|
values = self._extract_item_list(payload)
|
|||
|
|
for item in values:
|
|||
|
|
if not isinstance(item, dict):
|
|||
|
|
continue
|
|||
|
|
start_s, end_s = self._extract_start_end(item)
|
|||
|
|
if not start_s or not end_s:
|
|||
|
|
continue
|
|||
|
|
subject = (
|
|||
|
|
item.get("subject")
|
|||
|
|
or item.get("Subject")
|
|||
|
|
or item.get("name")
|
|||
|
|
or item.get("Name")
|
|||
|
|
or "Meeting"
|
|||
|
|
)
|
|||
|
|
# The same calendar view can be intercepted more than once (initial
|
|||
|
|
# load + the ArrowRight/ArrowLeft nudge used to trigger XHRs, or the
|
|||
|
|
# page-fetch/bearer/XHR-intercept paths overlapping), so the identical
|
|||
|
|
# event shows up in multiple captured payloads — sometimes with the
|
|||
|
|
# SAME instant expressed in different formats (UTC "Z" vs. an
|
|||
|
|
# already-shifted "+08:00" local offset). Comparing raw strings would
|
|||
|
|
# miss those, so normalize start/end to UTC before deduping.
|
|||
|
|
try:
|
|||
|
|
start_key = self._parse_time(start_s).astimezone(timezone.utc).isoformat()
|
|||
|
|
end_key = self._parse_time(end_s).astimezone(timezone.utc).isoformat()
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
start_key, end_key = str(start_s), str(end_s)
|
|||
|
|
dedupe_key = (str(subject).strip().lower(), start_key, end_key)
|
|||
|
|
if dedupe_key in seen:
|
|||
|
|
continue
|
|||
|
|
seen.add(dedupe_key)
|
|||
|
|
events.append(
|
|||
|
|
{
|
|||
|
|
"subject": str(subject),
|
|||
|
|
"start": start_s,
|
|||
|
|
"end": end_s,
|
|||
|
|
"is_cancelled": bool(
|
|||
|
|
item.get("isCancelled")
|
|||
|
|
or item.get("IsCancelled")
|
|||
|
|
or item.get("isCancelledEvent")
|
|||
|
|
),
|
|||
|
|
"is_online": bool(
|
|||
|
|
item.get("isOnlineMeeting")
|
|||
|
|
or item.get("IsOnlineMeeting")
|
|||
|
|
or item.get("onlineMeeting")
|
|||
|
|
or item.get("OnlineMeeting")
|
|||
|
|
or item.get("isOnlineMeeting")
|
|||
|
|
),
|
|||
|
|
"show_as": str(
|
|||
|
|
item.get("showAs") or item.get("ShowAs") or item.get("showas") or ""
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return events
|
|||
|
|
|
|||
|
|
def _extract_item_list(self, payload: Any) -> list[Any]:
|
|||
|
|
if isinstance(payload, list):
|
|||
|
|
return payload
|
|||
|
|
if not isinstance(payload, dict):
|
|||
|
|
return []
|
|||
|
|
for key in ("value", "Value", "Items", "items", "events", "Events", "Body"):
|
|||
|
|
nested = payload.get(key)
|
|||
|
|
if isinstance(nested, list):
|
|||
|
|
return nested
|
|||
|
|
if isinstance(nested, dict):
|
|||
|
|
# OWA Body.ResponseMessages...
|
|||
|
|
deeper = self._extract_item_list(nested)
|
|||
|
|
if deeper:
|
|||
|
|
return deeper
|
|||
|
|
# recursive shallow search for list of event-like dicts
|
|||
|
|
for v in payload.values():
|
|||
|
|
if isinstance(v, list) and v and isinstance(v[0], dict):
|
|||
|
|
sample = v[0]
|
|||
|
|
if any(k in sample for k in ("Start", "start", "Subject", "subject", "End", "end")):
|
|||
|
|
return v
|
|||
|
|
if isinstance(v, dict):
|
|||
|
|
deeper = self._extract_item_list(v)
|
|||
|
|
if deeper:
|
|||
|
|
return deeper
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
def _extract_start_end(self, item: dict[str, Any]) -> tuple[str | None, str | None]:
|
|||
|
|
start = item.get("start") or item.get("Start") or item.get("startTime") or item.get("StartTime")
|
|||
|
|
end = item.get("end") or item.get("End") or item.get("endTime") or item.get("EndTime")
|
|||
|
|
start_s = self._normalize_time_field(start)
|
|||
|
|
end_s = self._normalize_time_field(end)
|
|||
|
|
return start_s, end_s
|
|||
|
|
|
|||
|
|
def _normalize_time_field(self, value: Any) -> str | None:
|
|||
|
|
if value is None:
|
|||
|
|
return None
|
|||
|
|
if isinstance(value, str):
|
|||
|
|
# OWA legacy: /Date(1710000000000)/
|
|||
|
|
if value.startswith("/Date("):
|
|||
|
|
try:
|
|||
|
|
ms = int(value.split("(")[1].split(")")[0].split("+")[0].split("-")[0])
|
|||
|
|
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
return None
|
|||
|
|
return value
|
|||
|
|
if isinstance(value, (int, float)):
|
|||
|
|
# epoch ms or seconds
|
|||
|
|
ts = float(value)
|
|||
|
|
if ts > 1e12:
|
|||
|
|
ts = ts / 1000
|
|||
|
|
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
|||
|
|
if isinstance(value, dict):
|
|||
|
|
return (
|
|||
|
|
value.get("dateTime")
|
|||
|
|
or value.get("DateTime")
|
|||
|
|
or value.get("date")
|
|||
|
|
or value.get("Date")
|
|||
|
|
)
|
|||
|
|
return None
|