eight-hourr/backend/services/teams_calendar_scraper.py

1437 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"正在登入 Outlookheadless={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