Initial commit: MISF VOB download and upload automation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lubna-fatimaandClaude Sonnet 4.6 committed 2026-06-16 14:55:29 +05:30
commit c2dec96b1f
43 files changed
+10944

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""MISF OneDrive / Planner download automation."""
+51
View File
@@ -0,0 +1,51 @@
"""Resume checkpoint for multi-facility download runs."""
from __future__ import annotations
import json
from pathlib import Path
def load_checkpoint(path: Path) -> dict:
"""
Return checkpoint dict:
- last_completed_index: int
- completed_facilities: list[str]
Backward compatible with older files.
"""
if not path.is_file():
return {"last_completed_index": 0, "completed_facilities": []}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return {
"last_completed_index": int(data.get("last_completed_index", 0) or 0),
"completed_facilities": list(data.get("completed_facilities", []) or []),
}
except (json.JSONDecodeError, TypeError, ValueError):
return {"last_completed_index": 0, "completed_facilities": []}
def save_checkpoint(
path: Path,
*,
last_completed_index: int,
facility_name: str,
completed_facilities: list[str] | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"last_completed_index": last_completed_index,
"last_facility": facility_name,
"completed_facilities": completed_facilities or [],
},
indent=2,
),
encoding="utf-8",
)
def clear_checkpoint(path: Path) -> None:
if path.is_file():
path.unlink()
+891
View File
@@ -0,0 +1,891 @@
"""Chromium (Chrome-compatible) remote-debug startup and Selenium attach."""
from __future__ import annotations
import atexit
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import zipfile
from datetime import datetime
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from config import PROJECT_ROOT
from debug_log import write_debug as _agent_log
from run_log import log_step
_DEBUG_PORT = 9222
DEBUG_PORT = _DEBUG_PORT
_PROXY_CREDENTIALS: dict[str, str] = {}
_LAST_BROWSER_PATH: str | None = None
_BROWSER_PROCESS: subprocess.Popen | None = None
USER_DATA_DIR = os.path.join(
os.environ.get("USERPROFILE", str(Path.home())),
"Automation",
"ChromiumProfile_MISF_Download",
)
_DRIVERS_DIR = PROJECT_ROOT / ".drivers"
_CFT_DOWNLOAD_BASE = "https://storage.googleapis.com/chrome-for-testing-public"
DOWNLOADS_DIR = PROJECT_ROOT / "Input" / "PDFs"
_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+\.\d+)")
# Microsoft 365 hosts bypass HTTP proxy when split-tunnel is configured.
CHROME_PROXY_BYPASS_OFFICE = (
"*.office.com;*.office365.com;*.microsoft.com;*.live.com;*.outlook.com;"
"outlook.office.com;outlook.office365.com;login.microsoftonline.com;login.live.com;"
"aadcdn.msauth.net;aadcdn.msftauth.net"
)
def set_default_download_directory(path: Path | str, *, create: bool = False) -> None:
"""Set browser download prefs root (used at attach time). Per-facility CDP still applies."""
global DOWNLOADS_DIR
DOWNLOADS_DIR = Path(path).resolve()
if create:
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
def _allow_google_chrome_fallback() -> bool:
return os.environ.get("MISF_ALLOW_CHROME", "").strip().lower() in ("1", "true", "yes")
def _portable_chrome_win_roots() -> list[Path]:
return [
PROJECT_ROOT / "chrome-win" / "chrome-win",
PROJECT_ROOT / "chrome-win",
]
def _portable_chromium_executable() -> str | None:
"""Bundled Chromium under project ``chrome-win/`` (preferred for production)."""
for root in _portable_chrome_win_roots():
for name in ("chrome.exe", "chromium.exe"):
path = root / name
if path.is_file():
return str(path)
return None
def _bundled_chromedriver_for(browser_path: str) -> str | None:
"""``chromedriver.exe`` shipped next to portable ``chrome.exe``."""
if not browser_path:
return None
driver = Path(browser_path).parent / "chromedriver.exe"
if driver.is_file():
return str(driver)
return None
def _installed_chromium_paths() -> list[str]:
"""System-installed Chromium (not Google Chrome)."""
local = os.environ.get("LOCALAPPDATA", "")
profile = os.environ.get("USERPROFILE", str(Path.home()))
return [
r"C:\Program Files\Chromium\Application\chrome.exe",
r"C:\Program Files\Chromium\Application\chromium.exe",
r"C:\Program Files (x86)\Chromium\Application\chrome.exe",
os.path.join(local, r"Chromium\Application\chrome.exe"),
os.path.join(profile, r"scoop\apps\chromium\current\chrome.exe"),
]
def _prefer_installed_chromium() -> bool:
"""Skip ``chrome-win/`` and use a system Chromium install instead."""
return os.environ.get("MISF_USE_INSTALLED_CHROME", "").strip().lower() in (
"1",
"true",
"yes",
)
def _google_chrome_fallback_paths() -> list[str]:
local = os.environ.get("LOCALAPPDATA", "")
return [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
os.path.join(local, r"Google\Chrome\Application\chrome.exe"),
]
def find_chromium_executable() -> str | None:
"""
Resolve Chromium only (Google Chrome is opt-in via ``MISF_ALLOW_CHROME=1``).
Default order: ``chrome-win/`` portable bundle, then system Chromium.
Override: ``CHROMIUM_PATH`` / ``BROWSER_PATH``, or ``MISF_USE_INSTALLED_CHROME=1``.
"""
for env_key in ("CHROMIUM_PATH", "BROWSER_PATH"):
raw = os.environ.get(env_key, "").strip().strip('"')
if raw and os.path.isfile(raw):
return raw
portable = _portable_chromium_executable()
installed = [p for p in _installed_chromium_paths() if os.path.isfile(p)]
if _prefer_installed_chromium():
candidates = installed + ([portable] if portable else [])
else:
candidates = ([portable] if portable else []) + installed
if _allow_google_chrome_fallback():
candidates = candidates + [
p for p in _google_chrome_fallback_paths() if os.path.isfile(p)
]
for path in candidates:
if path:
_agent_log(
"A",
"src/chrome.py:find_chromium_executable",
"Selected browser executable",
{"basename": os.path.basename(path), "from_portable": "chrome-win" in path.lower()},
)
return path
_agent_log(
"A",
"src/chrome.py:find_chromium_executable",
"No Chromium executable found",
{"chrome_win_checked": [str(r) for r in _portable_chrome_win_roots()]},
)
return None
def _browser_runs_in_background() -> bool:
"""Default: foreground (visible). Set MISF_BROWSER_BACKGROUND=1 to start minimized."""
return os.environ.get("MISF_BROWSER_BACKGROUND", "").strip().lower() in (
"1",
"true",
"yes",
)
def _launch_browser_process(cmd: list[str]) -> subprocess.Popen:
"""Start Chromium detached; minimized on Windows when running in background."""
global _BROWSER_PROCESS
kwargs: dict = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
if sys.platform == "win32" and _browser_runs_in_background():
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 7 # SW_SHOWMINNOACTIVE — minimized, no focus steal
kwargs["startupinfo"] = startupinfo
_BROWSER_PROCESS = subprocess.Popen(cmd, **kwargs)
return _BROWSER_PROCESS
def stop_chrome_browser(driver: webdriver.Chrome | None = None) -> None:
"""Disconnect Selenium and terminate the Chromium process we launched."""
if driver is not None:
try:
driver.quit()
except Exception:
pass
global _BROWSER_PROCESS
proc = _BROWSER_PROCESS
_BROWSER_PROCESS = None
if proc is not None and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=8)
except Exception:
try:
proc.kill()
except Exception:
pass
if wait_for_debug_port(DEBUG_PORT, timeout=1):
_kill_processes_listening_on_port(DEBUG_PORT)
def _kill_processes_listening_on_port(port: int) -> None:
"""Best-effort cleanup when the stored Popen handle is unavailable."""
if sys.platform != "win32":
return
try:
output = subprocess.check_output(
["netstat", "-ano", "-p", "tcp"],
text=True,
errors="replace",
**_hidden_subprocess_kwargs(),
)
except Exception:
return
target_pids: set[int] = set()
needle = f":{port}"
for line in output.splitlines():
if needle not in line or "LISTENING" not in line.upper():
continue
parts = line.split()
if not parts:
continue
try:
target_pids.add(int(parts[-1]))
except ValueError:
continue
for pid in target_pids:
if pid <= 0:
continue
try:
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**_hidden_subprocess_kwargs(),
)
except Exception:
pass
def _apply_background_window(driver: webdriver.Chrome) -> None:
try:
driver.maximize_window()
except WebDriverException:
pass
def _bring_browser_to_front(driver: webdriver.Chrome) -> None:
"""Best-effort focus for scheduled/server runs so captures are not blank."""
if sys.platform != "win32":
return
try:
import ctypes
user32 = ctypes.windll.user32
hwnd = user32.GetForegroundWindow()
try:
rect = driver.get_window_rect()
target_x = int(rect.get("x", 0) + max(rect.get("width", 800) // 2, 1))
target_y = int(rect.get("y", 0) + max(rect.get("height", 600) // 2, 1))
user32.SetCursorPos(target_x, target_y)
except Exception:
pass
if hwnd:
user32.SetForegroundWindow(hwnd)
driver.switch_to.window(driver.current_window_handle)
except Exception:
pass
def prepare_browser_for_screenshot(driver: webdriver.Chrome) -> None:
"""Maximize and focus the automation browser before board screenshots."""
try:
driver.switch_to.default_content()
except Exception:
pass
_apply_background_window(driver)
_bring_browser_to_front(driver)
time.sleep(0.6)
def _hidden_subprocess_kwargs() -> dict:
"""Run helper processes without flashing a console window on Windows."""
if sys.platform != "win32":
return {}
kwargs: dict = {"creationflags": subprocess.CREATE_NO_WINDOW}
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0
kwargs["startupinfo"] = startupinfo
return kwargs
def _capture_desktop_powershell(path: Path) -> bool:
"""Primary monitor capture on Windows (includes taskbar date/time)."""
if sys.platform != "win32":
return False
out = str(path.resolve())
ps_script = f"""
Add-Type -AssemblyName System.Windows.Forms,System.Drawing
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$graphics = [System.Drawing.Graphics]::FromImage($bmp)
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$bmp.Save('{out.replace("'", "''")}', [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bmp.Dispose()
"""
try:
subprocess.run(
[
"powershell",
"-NoProfile",
"-WindowStyle",
"Hidden",
"-Command",
ps_script,
],
check=True,
timeout=20,
capture_output=True,
**_hidden_subprocess_kwargs(),
)
return path.is_file() and path.stat().st_size > 0
except Exception:
return False
def capture_desktop_screenshot(path: Path) -> bool:
"""Save a full-desktop PNG (taskbar clock visible). Falls back to False if unavailable."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
try:
from PIL import ImageGrab
ImageGrab.grab(all_screens=False).save(str(path), "PNG")
if path.is_file():
return True
except Exception:
pass
try:
import mss
import mss.tools
with mss.mss() as sct:
monitor = sct.monitors[1]
shot = sct.grab(monitor)
mss.tools.to_png(shot.rgb, shot.size, output=str(path))
return path.is_file()
except Exception:
pass
if _capture_desktop_powershell(path):
return True
try:
import pyautogui
pyautogui.screenshot(str(path))
return path.is_file()
except Exception:
pass
return False
def _is_image_mostly_black(path: Path, *, threshold: int = 18) -> bool:
"""True when a PNG is nearly all black (common on headless/disconnected server sessions)."""
try:
from PIL import Image
with Image.open(path) as img:
rgb = img.convert("RGB")
sample = rgb.resize((64, 64))
pixels = list(sample.getdata())
if not pixels:
return True
avg = sum(sum(p) for p in pixels) / (len(pixels) * 3)
return avg < threshold
except Exception:
return False
def _capture_browser_with_timestamp_bar(driver: webdriver.Chrome, path: Path) -> bool:
"""
Fallback when desktop capture is black: maximized browser PNG plus a taskbar-style clock strip.
"""
try:
import io
from PIL import Image, ImageDraw
prepare_browser_for_screenshot(driver)
png = driver.get_screenshot_as_png()
browser = Image.open(io.BytesIO(png)).convert("RGB")
bar_h = 48
canvas = Image.new("RGB", (browser.width, browser.height + bar_h), (30, 30, 30))
canvas.paste(browser, (0, 0))
draw = ImageDraw.Draw(canvas)
stamp = datetime.now().strftime("%I:%M %p %m/%d/%Y").lstrip("0")
draw.text((max(browser.width - 260, 8), browser.height + 14), stamp, fill=(255, 255, 255))
canvas.save(str(path), "PNG")
return path.is_file() and path.stat().st_size > 0
except Exception:
return False
def capture_fullscreen_screenshot(driver: webdriver.Chrome, path: Path) -> bool:
"""
Full-screen capture with system taskbar visible when an interactive desktop exists.
On scheduled/server sessions without a visible desktop, falls back to a maximized
browser screenshot plus a timestamp bar (instead of an all-black PNG).
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
log_step("Fullscreen screenshot", path=str(path))
prepare_browser_for_screenshot(driver)
if capture_desktop_screenshot(path) and not _is_image_mostly_black(path):
log_step("Fullscreen screenshot saved", path=str(path))
return True
if _is_image_mostly_black(path):
try:
path.unlink(missing_ok=True)
except OSError:
pass
if _capture_browser_with_timestamp_bar(driver, path):
log_step("Browser screenshot with timestamp bar saved", path=str(path), mode="server_fallback")
return True
driver.save_screenshot(str(path))
log_step("Viewport screenshot saved (desktop capture unavailable)", path=str(path))
return path.is_file()
def wait_for_debug_port(port: int = DEBUG_PORT, timeout: int = 40) -> bool:
start = time.time()
while time.time() - start < timeout:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("127.0.0.1", port)) == 0:
return True
time.sleep(1)
return False
def is_debug_port_open(port: int = DEBUG_PORT) -> bool:
return wait_for_debug_port(port, timeout=1)
def _version_from_portable_dir(browser_path: str) -> str:
"""Read e.g. 150.0.7852.0 from 150.0.7852.0.manifest next to chrome.exe."""
folder = Path(browser_path).parent
for manifest in folder.glob("*.manifest"):
match = _VERSION_RE.search(manifest.name)
if match:
return match.group(1)
return ""
def _resolve_browser_version(browser_path: str) -> str:
"""Best-effort version for the browser we launched (no hanging --version)."""
version = _get_browser_version_from_debug_port(timeout_secs=5.0)
if version:
return version
if browser_path:
version = _version_from_portable_dir(browser_path)
if version:
return version
return ""
def _ensure_chromedriver_cft(browser_version: str) -> str:
"""
Download chromedriver matching ``browser_version`` from Chrome for Testing.
Cached under ``.drivers/<version>/chromedriver.exe``.
"""
dest = _DRIVERS_DIR / browser_version / "chromedriver.exe"
if dest.is_file():
return str(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
zip_url = f"{_CFT_DOWNLOAD_BASE}/{browser_version}/win64/chromedriver-win64.zip"
zip_path = dest.parent / "chromedriver-win64.zip"
print(f"[INFO] Downloading ChromeDriver {browser_version} (Chrome for Testing)...")
_download_timeout = int(
os.environ.get("MISF_CHROMEDRIVER_DOWNLOAD_TIMEOUT", "60")
)
last_exc: Exception | None = None
for attempt in range(1, 4):
try:
req = urlopen(zip_url, timeout=_download_timeout)
with open(zip_path, "wb") as fh:
shutil.copyfileobj(req, fh)
break
except Exception as exc:
last_exc = exc
print(f"[WARN] ChromeDriver download attempt {attempt}/3 failed: {exc}")
if zip_path.exists():
zip_path.unlink(missing_ok=True)
time.sleep(2 * attempt)
else:
raise RuntimeError(
f"ChromeDriver {browser_version} download failed after 3 attempts: {last_exc}"
)
if not zipfile.is_zipfile(zip_path):
zip_path.unlink(missing_ok=True)
raise RuntimeError(
f"Downloaded ChromeDriver archive is not a valid ZIP: {zip_url}"
)
with zipfile.ZipFile(zip_path) as archive:
member = next(
(name for name in archive.namelist() if name.endswith("chromedriver.exe")),
None,
)
if not member:
raise RuntimeError(f"chromedriver.exe not found in {zip_url}")
archive.extract(member, dest.parent)
extracted = dest.parent / member
extracted.rename(dest)
try:
zip_path.unlink(missing_ok=True)
nested = dest.parent / "chromedriver-win64"
if nested.is_dir():
shutil.rmtree(nested, ignore_errors=True)
except OSError:
pass
print(f"[INFO] ChromeDriver installed -> {dest}")
return str(dest)
def _get_browser_version_from_debug_port(timeout_secs: float = 2.0) -> str:
"""
Read Chromium version from the running remote-debug instance.
Example payload: {"Browser":"Chrome/150.0.7852.0", ...}
"""
try:
with urlopen(f"http://127.0.0.1:{DEBUG_PORT}/json/version", timeout=timeout_secs) as resp:
raw = resp.read().decode("utf-8", errors="replace")
data = json.loads(raw)
browser = str(data.get("Browser", "")).strip()
# "Chrome/150.0.7852.0"
if "/" in browser:
return browser.split("/", 1)[1].strip()
return browser
except (URLError, TimeoutError, ValueError, OSError):
return ""
def create_proxy_auth_extension(
proxy_ip: str, proxy_port: str, proxy_username: str, proxy_password: str
) -> str:
manifest_json = """{
"version": "1.0.0",
"manifest_version": 2,
"name": "Chrome Proxy Auth Extension",
"permissions": [
"proxy", "tabs", "unlimitedStorage", "storage",
"<all_urls>", "webRequest", "webRequestBlocking"
],
"background": { "scripts": ["background.js"] },
"minimum_chrome_version": "22.0.0"
}"""
_u = json.dumps(proxy_username)
_p = json.dumps(proxy_password)
background_js = f"""function callbackFn(details) {{
return {{ authCredentials: {{ username: {_u}, password: {_p} }} }};
}}
chrome.webRequest.onAuthRequired.addListener(
callbackFn, {{urls: ["<all_urls>"]}}, ["blocking"]
);"""
ext_dir = Path(tempfile.mkdtemp(prefix="proxy_auth_ext_"))
atexit.register(shutil.rmtree, str(ext_dir), True)
(ext_dir / "manifest.json").write_text(manifest_json, encoding="utf-8")
(ext_dir / "background.js").write_text(background_js, encoding="utf-8")
print(f"[INFO] Proxy auth extension folder -> {ext_dir}")
return str(ext_dir)
def start_chrome_debug(
proxy_ip=None,
proxy_port=None,
proxy_username=None,
proxy_password=None,
) -> bool:
global _PROXY_CREDENTIALS
browser_path = find_chromium_executable()
if not browser_path:
raise RuntimeError(
"Chromium not found. Place the portable bundle in chrome-win\\chrome-win\\ "
"(chrome.exe + chromedriver.exe), install Chromium, or set CHROMIUM_PATH. "
"(Google Chrome: MISF_ALLOW_CHROME=1.)"
)
log_step(
"Launch Chromium",
executable=browser_path,
background=str(_browser_runs_in_background()).lower(),
)
global _LAST_BROWSER_PATH
_LAST_BROWSER_PATH = browser_path
os.makedirs(USER_DATA_DIR, exist_ok=True)
cmd = [
browser_path,
f"--remote-debugging-port={DEBUG_PORT}",
"--remote-allow-origins=*",
f"--user-data-dir={USER_DATA_DIR}",
"--disable-blink-features=AutomationControlled",
"--disable-popup-blocking",
"--no-first-run",
"--no-default-browser-check",
"--password-store=basic",
"--disable-restore-session-state",
"--disable-session-crashed-bubble",
"--hide-crash-restore-bubble",
"about:blank",
]
if _browser_runs_in_background():
cmd.append("--start-minimized")
if proxy_ip and proxy_port:
port_str = str(proxy_port).strip()
if not port_str.isdigit():
print(f"[WARN] Invalid proxy port '{port_str}' — skipping proxy")
_PROXY_CREDENTIALS = {}
else:
cmd.append(f"--proxy-server=http://{proxy_ip.strip()}:{port_str}")
cmd.append(
"--proxy-bypass-list=127.0.0.1;localhost;<-loopback>;"
+ CHROME_PROXY_BYPASS_OFFICE
)
log_step("Proxy configured", host=proxy_ip.strip(), port=port_str)
if proxy_username and proxy_password:
ext_folder = create_proxy_auth_extension(
proxy_ip.strip(),
port_str,
proxy_username.strip(),
proxy_password,
)
cmd.append(f"--load-extension={ext_folder}")
_PROXY_CREDENTIALS = {
"username": proxy_username.strip(),
"password": str(proxy_password),
}
else:
_PROXY_CREDENTIALS = {}
else:
print("[INFO] No proxy configured — direct connection")
_PROXY_CREDENTIALS = {}
if is_debug_port_open(DEBUG_PORT):
log_step("Debug port already in use — closing previous browser session")
stop_chrome_browser()
time.sleep(2)
_launch_browser_process(cmd)
if not wait_for_debug_port(DEBUG_PORT, timeout=40):
print("[ERROR] Browser debug port did not open in time")
return False
print("[INFO] Browser debug port ready")
return True
def _find_chromedriver_path() -> str | None:
"""
Return a chromedriver path matching the browser we started.
Prefers ``chromedriver.exe`` bundled in ``chrome-win/``. Otherwise downloads
a matching Chrome-for-Testing driver, then Selenium Manager.
"""
for env_key in ("CHROMEDRIVER_PATH", "WEBDRIVER_CHROME_DRIVER"):
raw = os.environ.get(env_key, "").strip().strip('"')
if raw and os.path.isfile(raw):
_agent_log(
"A",
"src/chrome.py:_find_chromedriver_path",
"Using explicit chromedriver path",
{"basename": os.path.basename(raw)},
)
return raw
browser_path = _LAST_BROWSER_PATH or ""
bundled = _bundled_chromedriver_for(browser_path)
if bundled:
log_step("Using bundled chromedriver", path=bundled)
_agent_log(
"A",
"src/chrome.py:_find_chromedriver_path",
"Using chrome-win chromedriver",
{"basename": os.path.basename(bundled)},
)
return bundled
browser_version = _resolve_browser_version(browser_path)
_agent_log(
"A",
"src/chrome.py:_find_chromedriver_path",
"Detected browser version",
{
"browser_version": browser_version,
"from_portable": "chrome-win" in (browser_path or "").lower(),
},
)
if browser_version:
try:
driver_path = _ensure_chromedriver_cft(browser_version)
_agent_log(
"A",
"src/chrome.py:_find_chromedriver_path",
"Chrome for Testing chromedriver ready",
{"browser_version": browser_version},
)
return driver_path
except Exception as exc:
print(
f"[WARN] Could not download ChromeDriver {browser_version}: {exc}"
)
_agent_log(
"A",
"src/chrome.py:_find_chromedriver_path",
"CfT chromedriver download failed",
{"error": str(exc)[:200]},
)
_agent_log(
"A",
"src/chrome.py:_find_chromedriver_path",
"Using Selenium Manager for chromedriver",
{},
)
print(
"[WARN] Browser version unknown — Selenium Manager may pick the wrong driver. "
"Set CHROMEDRIVER_PATH or use installed Chrome (default)."
)
return None
def _fill_proxy_auth_js_alert_if_present(
driver, username: str, password: str, wait_secs: float
) -> bool:
deadline = time.monotonic() + wait_secs
while time.monotonic() < deadline:
try:
alert = driver.switch_to.alert
alert.send_keys(Keys.CONTROL + "a")
alert.send_keys(username)
alert.send_keys(Keys.TAB)
alert.send_keys(Keys.CONTROL + "a")
alert.send_keys(password)
alert.accept()
return True
except Exception:
time.sleep(0.25)
return False
def _try_fill_proxy_auth_native_dialog(username: str, password: str) -> bool:
if sys.platform != "win32":
return False
try:
import pyautogui
except ImportError:
print("[INFO] pyautogui not installed — skip native proxy dialog fill")
return False
try:
time.sleep(0.6)
pyautogui.FAILSAFE = True
pyautogui.hotkey("ctrl", "a")
pyautogui.press("backspace")
pyautogui.write(username, interval=0.02)
pyautogui.press("tab")
pyautogui.hotkey("ctrl", "a")
pyautogui.press("backspace")
pyautogui.write(password, interval=0.02)
pyautogui.press("enter")
return True
except Exception as exc:
print(f"[WARN] Native proxy keyboard fill failed: {exc}")
return False
def _microsoft_login_visible(driver) -> bool:
try:
url = (driver.current_url or "").lower()
if "login.microsoftonline.com" in url or "login.live.com" in url:
return True
return bool(
driver.find_elements(By.CSS_SELECTOR, "input[type='email']")
or driver.find_elements(By.NAME, "loginfmt")
)
except Exception:
return False
def dismiss_proxy_auth_popup(
driver,
username: str,
password: str,
wait_secs: float = 5.0,
max_attempts: int = 3,
) -> bool:
for attempt in range(1, max_attempts + 1):
if _fill_proxy_auth_js_alert_if_present(driver, username, password, wait_secs):
print(f"[INFO] Proxy auth handled (JS dialog) attempt {attempt}/{max_attempts}")
return True
if attempt < max_attempts:
time.sleep(2)
if _try_fill_proxy_auth_native_dialog(username, password):
time.sleep(2)
if _microsoft_login_visible(driver):
print("[INFO] Microsoft login visible after proxy auth — proceeding")
return True
if _microsoft_login_visible(driver):
print("[INFO] Microsoft login page present — proxy session usable")
return True
print("[WARN] Proxy auth popup could not be dismissed")
return False
def attach_browser(
retries: int = 5,
download_dir: Path | str | None = None,
*,
create_download_dir: bool = False,
) -> webdriver.Chrome | None:
driver_path = _find_chromedriver_path()
service = Service(driver_path) if driver_path else Service()
dl_dir = Path(download_dir) if download_dir else DOWNLOADS_DIR
if create_download_dir:
dl_dir.mkdir(parents=True, exist_ok=True)
for attempt in range(1, retries + 1):
try:
log_step("Attach browser", attempt=f"{attempt}/{retries}", download_dir=str(dl_dir))
chrome_options = Options()
chrome_options.add_experimental_option(
"debuggerAddress", f"127.0.0.1:{DEBUG_PORT}"
)
chrome_options.add_experimental_option(
"prefs",
{
"download.default_directory": str(dl_dir),
"download.prompt_for_download": False,
"plugins.always_open_pdf_externally": True,
"download.directory_upgrade": True,
"credentials_enable_service": False,
"profile.password_manager_enabled": False,
},
)
driver = webdriver.Chrome(service=service, options=chrome_options)
_ = driver.current_url
_apply_background_window(driver)
driver.set_page_load_timeout(90)
driver.set_script_timeout(30)
log_step("Browser attached successfully")
return driver
except WebDriverException as exc:
log_step("Attach failed", attempt=f"{attempt}/{retries}", error=str(exc)[:200])
if attempt < retries:
time.sleep(3)
print("[ERROR] Failed to attach browser after all retries")
return None
+273
View File
@@ -0,0 +1,273 @@
"""OneDrive / Planner download configuration from Input/input.xlsx."""
from __future__ import annotations
import logging
import os
import re
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import pandas as pd
from totp_utils import validate_totp_secret
if getattr(sys, "frozen", False):
PROJECT_ROOT = Path(sys.executable).parent
else:
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONFIG_FILE = PROJECT_ROOT / "Input" / "input.xlsx"
TODAY_ISO_DATE = datetime.now().strftime("%Y-%m-%d")
EXPECTED_FACILITY_COUNT = 54
DEFAULT_ONEDRIVE_URL = (
"https://netorg393890-my.sharepoint.com/personal/joy_accesspsychology_org"
"/_layouts/15/onedrive.aspx"
"?id=%2Fpersonal%2Fjoy%5Faccesspsychology%5Forg%2FDocuments"
"%2FAccess%20Facility%20Spreadsheets3"
)
DEFAULT_PLANNER_URL = (
"https://planner.cloud.microsoft/webui/mytasks/assignedtome/view/grid"
"?tid=0c15ad2c-19dd-4ded-9cd1-20d344386414"
)
# Rows like REPLACE_WITH_54TH_FACILITY_NAME are skipped when loading facilities.
_FACILITY_PLACEHOLDER_RE = re.compile(
r"^REPLACE_WITH_.*",
re.IGNORECASE,
)
_log = logging.getLogger(__name__)
@dataclass(frozen=True)
class AppConfig:
proxy_ip: str
proxy_port: str
proxy_username: str
proxy_password: str
onedrive_username: str
onedrive_password: str
microsoft_totp_secret: str
onedrive_url: str
planner_url: str
pdf_folder_path: str
def _load_dotenv_file() -> None:
"""Load .env from project root into os.environ (no-override: existing vars take priority)."""
env_path = PROJECT_ROOT / ".env"
if not env_path.is_file():
return
try:
from dotenv import load_dotenv
load_dotenv(dotenv_path=env_path, override=False)
except ImportError:
# Fallback: basic KEY=VALUE parser (no multi-line or comment support)
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
def _env(name: str, *aliases: str) -> str:
for key in (name, *aliases):
val = os.environ.get(key, "").strip()
if val:
return val
return ""
def _read_config_key_value_sheet(config_path: Path | None = None) -> dict[str, str]:
path = config_path or CONFIG_FILE
if not path.exists():
raise FileNotFoundError(f"input.xlsx not found at {path}")
try:
df = pd.read_excel(str(path), sheet_name="Sheet1", header=None, usecols=[0, 1])
except Exception as exc:
raise RuntimeError(
f"Could not read config from {path} (Sheet1): {exc}"
) from exc
def _clean(val) -> str:
s = str(val).strip()
return "" if s.lower() in ("nan", "none", "") else s
if len(df.columns) < 2:
raise RuntimeError(
f"Config sheet Sheet1 in {path} must have at least 2 columns (Key | Value)."
)
config: dict[str, str] = {}
for _, row in df.iterrows():
key = _clean(row[0])
value = _clean(row[1])
if key:
config[key] = value
return config
def missing_onedrive_credentials(
onedrive_username: str,
onedrive_password: str,
microsoft_totp_secret: str,
) -> list[str]:
missing = []
if not onedrive_username:
missing.append("OneDrive Username")
if not onedrive_password:
missing.append("OneDrive Password")
if not microsoft_totp_secret:
missing.append("Microsoft TOTP Secret")
return missing
def credentials_setup_message(missing: list[str]) -> str:
keys = ", ".join(missing)
return (
f"Missing: {keys}\n\n"
f"Option A — edit {CONFIG_FILE} Sheet1, column B next to each key.\n"
f"Option B — copy .env.example to .env in the project root and set:\n"
" ONEDRIVE_USERNAME, ONEDRIVE_PASSWORD, MICROSOFT_TOTP_SECRET\n"
" PLANNER_URL (optional)\n"
)
def read_app_config(config_path: Path | None = None) -> AppConfig:
"""Load full application config from input.xlsx with .env fallbacks."""
_load_dotenv_file()
config = _read_config_key_value_sheet(config_path)
proxy_ip = config.get("Proxy IP", "") or _env("PROXY_IP")
proxy_port = config.get("Proxy Port", "") or _env("PROXY_PORT")
proxy_username = config.get("Proxy Username", "") or _env("PROXY_USERNAME")
proxy_password = config.get("Proxy Password", "") or _env("PROXY_PASSWORD")
onedrive_username = config.get("OneDrive Username", "") or _env(
"ONEDRIVE_USERNAME", "OneDrive Username"
)
onedrive_password = config.get("OneDrive Password", "") or _env(
"ONEDRIVE_PASSWORD", "OneDrive Password"
)
microsoft_totp_secret = (
config.get("Microsoft TOTP Secret", "")
or config.get("OTP Secret", "")
or _env("MICROSOFT_TOTP_SECRET", "OTP_SECRET", "TOTP_SECRET")
)
onedrive_url = config.get("OneDrive URL", "") or _env("ONEDRIVE_URL") or DEFAULT_ONEDRIVE_URL
planner_url = (
config.get("Planner URL", "")
or config.get("Planner URL ", "")
or _env("PLANNER_URL")
or DEFAULT_PLANNER_URL
)
pdf_folder_path = config.get("pdf_folder_path", "") or _env("PDF_FOLDER_PATH")
_log.info("Proxy -> %s:%s", proxy_ip, proxy_port)
_log.info("OneDrive User -> %s", onedrive_username)
_log.info("Planner URL -> %s...", planner_url[:80])
_log.info("PDF folder path -> %s", pdf_folder_path or "(default Input/PDFs)")
return AppConfig(
proxy_ip=proxy_ip,
proxy_port=proxy_port,
proxy_username=proxy_username,
proxy_password=proxy_password,
onedrive_username=onedrive_username,
onedrive_password=onedrive_password,
microsoft_totp_secret=microsoft_totp_secret,
onedrive_url=onedrive_url,
planner_url=planner_url,
pdf_folder_path=pdf_folder_path,
)
def read_onedrive_config(config_path: Path | None = None) -> tuple:
"""
Legacy tuple API. Prefer :func:`read_app_config`.
Returns
-------
(proxy_ip, proxy_port, proxy_username, proxy_password,
onedrive_username, onedrive_password, microsoft_totp_secret,
onedrive_url, pdf_folder_path, planner_url)
"""
cfg = read_app_config(config_path)
return (
cfg.proxy_ip,
cfg.proxy_port,
cfg.proxy_username,
cfg.proxy_password,
cfg.onedrive_username,
cfg.onedrive_password,
cfg.microsoft_totp_secret,
cfg.onedrive_url,
cfg.pdf_folder_path,
cfg.planner_url,
)
def _is_placeholder_facility(name: str) -> bool:
return bool(_FACILITY_PLACEHOLDER_RE.match(name.strip()))
def read_facility_list(
config_path: Path | None = None,
*,
skip_placeholders: bool = True,
warn_if_not_expected_count: bool = True,
) -> list[str]:
path = config_path or CONFIG_FILE
if not path.exists():
raise FileNotFoundError(f"input.xlsx not found at {path}")
try:
df = pd.read_excel(str(path), sheet_name="Facilities", usecols=[0])
except ValueError as exc:
raise RuntimeError(
f"Sheet 'Facilities' not found in {path}. "
"Add a sheet with column 'Facility Name' (one facility per row)."
) from exc
# Always use the first column regardless of its header name.
col = df.columns[0]
names: list[str] = []
skipped: list[str] = []
for val in df[col]:
s = str(val).strip()
if not s or s.lower() in ("nan", "none", "facility name"):
continue
if skip_placeholders and _is_placeholder_facility(s):
skipped.append(s)
continue
names.append(s)
if skipped:
_log.warning(
"Skipped %d placeholder facility row(s): %s",
len(skipped),
", ".join(skipped[:3]),
)
if not names:
raise RuntimeError(f"No facility names found in 'Facilities' sheet of {path}.")
if warn_if_not_expected_count and len(names) != EXPECTED_FACILITY_COUNT:
_log.warning(
"Expected %d facilities, found %d (check Facilities sheet names).",
EXPECTED_FACILITY_COUNT,
len(names),
)
_log.info("Loaded %d facility name(s) from Facilities sheet", len(names))
return names
+60
View File
@@ -0,0 +1,60 @@
"""Shared NDJSON debug logging (MISF_DEBUG=1 only). No secrets/PII."""
from __future__ import annotations
import json
import os
import time
import uuid
from config import PROJECT_ROOT
_DEBUG_LOG = PROJECT_ROOT / "debug.log"
_AGENT_SESSION_LOG = PROJECT_ROOT / "debug-6d994d.log"
_AGENT_SESSION_ID = "6d994d"
def write_agent_session_debug(
hypothesis_id: str, location: str, message: str, data: dict
) -> None:
"""NDJSON debug log for Cursor agent debug sessions (MISF_DEBUG=1 only)."""
# region agent log
if os.environ.get("MISF_DEBUG", "").strip() not in ("1", "true", "yes"):
return
try:
ts_ms = int(time.time() * 1000)
payload = {
"sessionId": _AGENT_SESSION_ID,
"runId": os.environ.get("MISF_RUN_ID", "run1"),
"hypothesisId": hypothesis_id,
"id": f"log_{ts_ms}_{uuid.uuid4().hex[:8]}",
"timestamp": ts_ms,
"location": location,
"message": message,
"data": data,
}
with _AGENT_SESSION_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
except Exception:
pass
# endregion
def write_debug(hypothesis_id: str, location: str, message: str, data: dict) -> None:
if os.environ.get("MISF_DEBUG", "").strip() not in ("1", "true", "yes"):
return
try:
ts_ms = int(time.time() * 1000)
payload = {
"sessionId": os.environ.get("MISF_SESSION_ID", "local"),
"runId": os.environ.get("MISF_RUN_ID", "run1"),
"hypothesisId": hypothesis_id,
"id": f"log_{ts_ms}_{uuid.uuid4().hex[:8]}",
"timestamp": ts_ms,
"location": location,
"message": message,
"data": data,
}
with _DEBUG_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
except Exception:
pass
+118
View File
@@ -0,0 +1,118 @@
"""Live log viewer for windowed MISF_VOB_Download.exe (no console)."""
from __future__ import annotations
import logging
import queue
import threading
import tkinter as tk
from collections.abc import Callable
from tkinter import scrolledtext
class _TkLogHandler(logging.Handler):
"""Thread-safe logging handler that forwards records to a Tk text widget."""
def __init__(self, text_widget: scrolledtext.ScrolledText, root: tk.Tk) -> None:
super().__init__()
self._text = text_widget
self._root = root
self._queue: queue.Queue[str] = queue.Queue()
self._root.after(100, self._drain_queue)
def emit(self, record: logging.LogRecord) -> None:
try:
msg = self.format(record)
except Exception:
self.handleError(record)
return
self._queue.put(msg + "\n")
def _drain_queue(self) -> None:
while True:
try:
line = self._queue.get_nowait()
except queue.Empty:
break
self._text.configure(state="normal")
self._text.insert(tk.END, line)
self._text.see(tk.END)
self._text.configure(state="disabled")
self._root.after(100, self._drain_queue)
def run_with_live_logs(workflow: Callable[[], int]) -> int:
"""
Show a scrollable log window and run *workflow* on a background thread.
Returns the workflow exit code after the user closes the window.
"""
root = tk.Tk()
root.title("MISF VOB Download — Live Log")
root.geometry("920x560")
root.minsize(640, 360)
header = tk.Label(
root,
text="Download in progress — logs update live below",
anchor="w",
font=("Segoe UI", 10, "bold"),
)
header.pack(fill="x", padx=10, pady=(10, 4))
text = scrolledtext.ScrolledText(
root,
wrap="word",
state="disabled",
font=("Consolas", 10),
)
text.pack(fill="both", expand=True, padx=10, pady=4)
status_var = tk.StringVar(value="Running...")
status_label = tk.Label(root, textvariable=status_var, anchor="w")
status_label.pack(fill="x", padx=10, pady=(0, 6))
close_btn = tk.Button(root, text="Close", state="disabled", width=12)
close_btn.pack(pady=(0, 10))
fmt = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
handler = _TkLogHandler(text, root)
handler.setFormatter(fmt)
handler.setLevel(logging.INFO)
root_logger = logging.getLogger()
root_logger.addHandler(handler)
result: dict[str, int] = {"code": 0}
def _run_workflow() -> None:
try:
result["code"] = workflow()
except SystemExit as exc:
result["code"] = int(exc.code) if exc.code is not None else 0
except Exception:
result["code"] = 1
logging.getLogger(__name__).exception("Workflow failed")
finally:
def _on_done() -> None:
if result["code"] == 0:
status_var.set("Complete — you may close this window.")
else:
status_var.set(f"Finished with errors (exit {result['code']}). See log above.")
close_btn.configure(state="normal")
root.after(0, _on_done)
worker = threading.Thread(target=_run_workflow, daemon=True)
worker.start()
def _close() -> None:
root_logger.removeHandler(handler)
root.destroy()
close_btn.configure(command=_close)
root.protocol("WM_DELETE_WINDOW", _close)
root.mainloop()
return result["code"]
+68
View File
@@ -0,0 +1,68 @@
"""Structured logging for MISF download runs."""
from __future__ import annotations
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
def setup_logging(project_root: Path, *, run_date: str | None = None) -> Path:
"""
Configure root logger and misf.run logger: console + download.log + moves.log.
Returns the main download log file path.
"""
date_str = run_date or datetime.now().strftime("%Y-%m-%d")
log_dir = project_root / "Output" / date_str / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / "download.log"
moves_path = log_dir / "moves.log"
level_name = os.environ.get("MISF_LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
fmt = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
root = logging.getLogger()
root.setLevel(level)
# Avoid duplicate handlers on repeated setup in same process.
for h in list(root.handlers):
root.removeHandler(h)
# Windowed .exe: log files only (no console / live log window).
if not getattr(sys, "frozen", False):
console = logging.StreamHandler()
console.setLevel(level)
console.setFormatter(fmt)
root.addHandler(console)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setLevel(level)
file_handler.setFormatter(fmt)
root.addHandler(file_handler)
moves_handler = logging.FileHandler(moves_path, encoding="utf-8")
moves_handler.setLevel(level)
moves_handler.setFormatter(fmt)
run_logger = logging.getLogger("misf.run")
run_logger.setLevel(level)
run_logger.propagate = True
run_logger.addHandler(moves_handler)
# Module loggers used across the app
for name in ("misf", "src.planner_download", "src.onedrive_planner", "src.chrome", "src.config"):
logging.getLogger(name).setLevel(level)
logging.getLogger("misf.run").info(
"Logging enabled -> %s and %s (filter [STEP] in moves.log)",
log_path,
moves_path,
)
return log_path
File diff suppressed because it is too large. Load diff
+747
View File
@@ -0,0 +1,747 @@
# ================================================================
# MISF — OneDrive / Planner face-sheet download (orchestrator)
#
# Entry point: python run_download.py (from repository root)
# See README.md for the full business workflow and configuration.
# ================================================================
from __future__ import annotations
import argparse
import logging
import shutil
import sys
import time
import traceback
import tkinter as tk
from datetime import datetime
from pathlib import Path
from tkinter import messagebox
import pandas as pd
from checkpoint import clear_checkpoint, load_checkpoint, save_checkpoint
from chrome import (
attach_browser,
dismiss_proxy_auth_popup,
set_default_download_directory,
start_chrome_debug,
stop_chrome_browser,
)
from config import (
CONFIG_FILE,
PROJECT_ROOT,
TODAY_ISO_DATE,
AppConfig,
credentials_setup_message,
missing_onedrive_credentials,
read_app_config,
read_facility_list,
)
from logging_setup import setup_logging
from run_log import log_step, log_url
from onedrive_planner import (
FacilityNotFoundError,
FacilityRunResult,
_remove_empty_directory,
_sanitize_folder_name,
configure_http_download_proxy,
consolidate_browser_to_single_tab,
login_and_open_planner,
process_facility,
reset_run_state,
save_failure_screenshot,
)
from totp_utils import validate_totp_secret
from debug_log import write_debug as _agent_log
_log = logging.getLogger(__name__)
def _show_error_dialog(title: str, message: str) -> None:
try:
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
messagebox.showerror(title, message, parent=root)
root.destroy()
except Exception as dialog_err:
_log.error("%s: %s", title, message)
_log.warning("Could not show messagebox: %s", dialog_err)
def _validate_deployment_layout() -> None:
"""Fail fast on a new machine when required folders/files are missing."""
issues: list[str] = []
example = PROJECT_ROOT / "Input" / "input.xlsx.example"
if not CONFIG_FILE.is_file():
hint = (
f"Copy and rename:\n {example}\n ->\n {CONFIG_FILE}"
if example.is_file()
else f"Create config file:\n {CONFIG_FILE}"
)
issues.append(f"Missing Input\\input.xlsx\n{hint}")
chrome_exe = PROJECT_ROOT / "chrome-win" / "chrome-win" / "chrome.exe"
chrome_driver = PROJECT_ROOT / "chrome-win" / "chrome-win" / "chromedriver.exe"
if not chrome_exe.is_file():
issues.append(
"Missing portable browser folder:\n"
f" {chrome_exe}\n"
"Copy the entire chrome-win\\ folder from the original machine "
"into the same folder as MISF_VOB_Download.exe."
)
elif not chrome_driver.is_file():
issues.append(
"Missing chromedriver next to portable chrome.exe:\n"
f" {chrome_driver}"
)
if issues:
message = (
"This PC is not set up to run MISF VOB Download yet.\n\n"
+ "\n\n".join(issues)
+ "\n\nSee README_PRODUCTION.txt in the app folder."
)
_show_error_dialog("MISF VOB Download — Setup Required", message)
raise FileNotFoundError(message.split("\n", 1)[0])
def _close_blank_tabs(driver) -> None:
"""Close any about:blank / empty tabs left by the Microsoft auth flow."""
try:
active = driver.current_window_handle
for handle in list(driver.window_handles):
if handle == active:
continue
try:
driver.switch_to.window(handle)
url = (driver.current_url or "").strip().lower()
if url in ("about:blank", "", "data:,"):
driver.close()
_log.info("Closed blank tab (%s)", handle)
except Exception:
pass
driver.switch_to.window(active)
except Exception as exc:
_log.warning("_close_blank_tabs: %s", exc)
def _quit_browser(
driver, *, keep_browser: bool, is_proxy_error: bool = False
) -> None:
"""Close the browser unless keep_browser is set or a proxy error occurred."""
if keep_browser or is_proxy_error:
return
stop_chrome_browser(driver)
log_step("Browser closed")
def _failure_status(exc: Exception) -> str:
if isinstance(exc, FacilityNotFoundError):
return "Not appear"
err_msg = str(exc).lower()
if "no planner plan found" in err_msg or "plan not found" in err_msg:
return "Plan not found"
if "no files" in err_msg or "attachment" in err_msg:
return "No attachments"
return "FAILED"
def _success_status(
file_count: int,
tasks_total: int,
*,
tasks_with_downloads: int = 0,
tasks_with_attachments: int = 0,
tasks_skipped_no_attachment: int = 0,
) -> str:
if tasks_total <= 0:
return "No tasks"
if tasks_with_attachments <= 0:
if tasks_skipped_no_attachment > 0 or tasks_total > 0:
return "No attachments"
return "No tasks"
if tasks_with_downloads <= 0 or file_count <= 0:
return "Download failed"
if tasks_with_downloads >= tasks_with_attachments:
return "Success"
return "Partial"
def _format_status_changed(
tasks_with_downloads: int,
status_changed: int,
*,
via_task_details: int = 0,
) -> str:
if tasks_with_downloads <= 0:
return "N/A"
if status_changed >= tasks_with_downloads:
prefix = "Yes"
elif status_changed > 0:
prefix = "Partial"
else:
prefix = "No"
label = f"{prefix} - {status_changed}/{tasks_with_downloads} tasks"
if via_task_details > 0:
label += f" ({via_task_details} via task details)"
return label
def _write_download_summary(
results: dict[str, dict[str, object]],
failures: list[tuple[str, str, str]],
summary_path: Path,
*,
planner_url: str,
run_timestamp: str,
) -> None:
failure_names = {facility for facility, _, _ in failures}
rows = []
for facility, payload in results.items():
if facility in failure_names:
continue
original_paths = list(payload.get("original", []) or [])
file_count = len(original_paths)
tasks_total = int(payload.get("tasks_total", 0) or 0)
tasks_with_downloads = int(payload.get("tasks_with_downloads", 0) or 0)
tasks_with_attachments = int(payload.get("tasks_with_attachments", 0) or 0)
tasks_skipped_no_attachment = int(
payload.get("tasks_skipped_no_attachment", 0) or 0
)
status_changed = int(payload.get("status_changed", 0) or 0)
via_task_details = int(payload.get("status_via_task_details", 0) or 0)
rows.append({
"Run Timestamp": run_timestamp,
"Facility Name": facility,
"Files Downloaded": file_count,
"Status": _success_status(
file_count,
tasks_total,
tasks_with_downloads=tasks_with_downloads,
tasks_with_attachments=tasks_with_attachments,
tasks_skipped_no_attachment=tasks_skipped_no_attachment,
),
"Status Changed": _format_status_changed(
tasks_with_downloads,
status_changed,
via_task_details=via_task_details,
),
})
for facility, err, status in failures:
rows.append({
"Run Timestamp": run_timestamp,
"Facility Name": facility,
"Files Downloaded": 0,
"Status": status,
"Status Changed": "N/A",
})
summary_path.parent.mkdir(parents=True, exist_ok=True)
pd.DataFrame(rows).to_excel(summary_path, index=False, sheet_name="Download")
_log.info("Download summary -> %s", summary_path)
def _unique_workable_filename(workable_root: Path, base_name: str, ext: str) -> Path:
"""
Allocate a unique file path inside Workable/ using _N suffix when needed.
"""
ext = ext if ext.startswith(".") else f".{ext}"
first = workable_root / f"{base_name}{ext}"
if not first.exists():
return first
for n in range(2, 1000):
candidate = workable_root / f"{base_name}_{n}{ext}"
if not candidate.exists():
return candidate
raise RuntimeError(f"Could not allocate workable filename for {base_name}{ext}")
def _build_workable_files(
*,
facility_name: str,
downloaded: list[Path],
output_root: Path,
today_iso: str,
) -> tuple[list[Path], Path]:
"""
Copy downloaded files into Output/<date>/Workable with standardized names.
- No facility folders are created under Workable.
- Files are written directly under Workable/ with unique names.
"""
ddmmyyyy = datetime.strptime(today_iso, "%Y-%m-%d").strftime("%d-%m-%Y")
fac_safe = _sanitize_folder_name(facility_name)
workable_root = output_root / today_iso / "Workable"
medicare_root = output_root / today_iso / "Workable_Medicare"
workable_paths: list[Path] = []
if not downloaded:
return workable_paths, workable_root
workable_root.mkdir(parents=True, exist_ok=True)
medicare_root.mkdir(parents=True, exist_ok=True)
for i, src in enumerate(downloaded, start=1):
ext = src.suffix or ".pdf"
base = f"{ddmmyyyy}_{fac_safe}" if len(downloaded) == 1 else f"{ddmmyyyy}_{fac_safe}_{i}"
dst = _unique_workable_filename(workable_root, base, ext)
try:
shutil.copy2(src, dst)
except OSError as exc:
_log.error("Failed to copy %s -> %s: %s", src.name, dst, exc)
raise
workable_paths.append(dst)
med_dst = medicare_root / dst.name
if med_dst.exists():
med_dst = _unique_workable_filename(medicare_root, dst.stem, ext)
try:
shutil.copy2(dst, med_dst)
except OSError as exc:
_log.error("Failed to mirror %s -> %s: %s", dst.name, med_dst, exc)
raise
_log.info(
"Workable files copied (%d) -> %s and %s",
len(workable_paths),
workable_root,
medicare_root,
)
return workable_paths, workable_root
def _prune_empty_date_output_dirs(output_root: Path, today_iso: str) -> None:
"""Remove empty Original/Workable day folders when no files were saved."""
for sub in ("Original", "Workable", "Workable_Medicare"):
root = output_root / today_iso / sub
if not root.is_dir():
continue
for child in list(root.iterdir()):
if child.is_dir() and not any(child.iterdir()):
try:
child.rmdir()
except OSError:
pass
try:
if not any(root.iterdir()):
root.rmdir()
except OSError:
pass
def _remove_screenshots_under_output(output_root: Path) -> int:
"""Delete legacy _screenshots folders under Output (Original/Workable/etc.)."""
removed = 0
if not output_root.is_dir():
return removed
for shots_dir in output_root.rglob("_screenshots"):
if not shots_dir.is_dir():
continue
try:
shutil.rmtree(shots_dir)
removed += 1
_log.info("Removed screenshot folder: %s", shots_dir)
except Exception as exc:
_log.warning("Could not remove %s: %s", shots_dir, exc)
return removed
def _filter_facilities(
facilities: list[str],
*,
facility_limit: int,
facility_name: str | None,
) -> list[str]:
if facility_name:
name = facility_name.strip()
matches = [f for f in facilities if f.lower() == name.lower()]
if not matches:
raise ValueError(
f"Facility '{name}' not found on Facilities sheet. "
f"Loaded {len(facilities)} names."
)
return matches
if facility_limit > 0:
return facilities[:facility_limit]
return facilities
def run_download_workflow(
*,
facility_limit: int = 0,
facility_name: str | None = None,
resume: bool = False,
keep_browser: bool = False,
screenshot_on_error: bool = False,
) -> None:
"""
Download face-sheet PDFs from Planner for configured facilities.
"""
reset_run_state()
run_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_path = setup_logging(PROJECT_ROOT)
_agent_log(
"A",
"src/planner_download.py:run_download_workflow",
"Run started",
{
"limit": facility_limit,
"facility": facility_name or "",
"resume": bool(resume),
"keep_browser": bool(keep_browser),
},
)
_log.info("=" * 66)
_log.info(" MISF OneDrive / Planner Download — Start")
_log.info(" Run date: %s", TODAY_ISO_DATE)
_log.info(" Log file: %s", log_path)
_log.info("=" * 66)
log_step("Run started", date=TODAY_ISO_DATE, log=str(log_path))
driver = None
checkpoint_path = PROJECT_ROOT / "Output" / TODAY_ISO_DATE / "checkpoint.json"
try:
_validate_deployment_layout()
cfg: AppConfig = read_app_config()
log_step("Config loaded", facilities="from input.xlsx", planner_url=(cfg.planner_url or "")[:120])
_agent_log(
"B",
"src/planner_download.py:run_download_workflow",
"Config loaded",
{
"planner_url_prefix": (cfg.planner_url or "")[:120],
"proxy_set": bool(cfg.proxy_ip and cfg.proxy_port),
"pdf_folder_set": bool(cfg.pdf_folder_path.strip()),
},
)
missing = missing_onedrive_credentials(
cfg.onedrive_username,
cfg.onedrive_password,
cfg.microsoft_totp_secret,
)
if missing:
raise ValueError(credentials_setup_message(missing))
validate_totp_secret(cfg.microsoft_totp_secret)
configure_http_download_proxy(
cfg.proxy_ip,
cfg.proxy_port,
cfg.proxy_username,
str(cfg.proxy_password) if cfg.proxy_password else None,
)
# Always download into Output/<date>/Original/<facility>/ to keep originals.
output_root = PROJECT_ROOT / "Output"
removed_shots = _remove_screenshots_under_output(output_root)
if removed_shots:
log_step("Removed legacy screenshot folders", count=removed_shots)
original_root = output_root / TODAY_ISO_DATE / "Original"
failure_log_dir = output_root / TODAY_ISO_DATE / "logs" / "failures"
set_default_download_directory(original_root, create=False)
facilities = read_facility_list()
facilities = _filter_facilities(
facilities,
facility_limit=facility_limit,
facility_name=facility_name,
)
start_index = 0
completed: set[str] = set()
if resume:
ckpt = load_checkpoint(checkpoint_path)
start_index = int(ckpt.get("last_completed_index", 0) or 0)
completed = {str(x) for x in (ckpt.get("completed_facilities", []) or [])}
if start_index > 0 or completed:
_log.info(
"Resuming after facility index %d (%d remaining); %d completed cached",
start_index,
max(0, len(facilities) - start_index),
len(completed),
)
else:
clear_checkpoint(checkpoint_path)
if start_index >= len(facilities):
_log.warning("Checkpoint past end of facility list — starting from beginning")
start_index = 0
clear_checkpoint(checkpoint_path)
log_step("Start browser (Chromium remote debug)")
_log.info("Starting Chromium (Chrome-compatible)...")
if not start_chrome_debug(
cfg.proxy_ip,
cfg.proxy_port,
cfg.proxy_username,
cfg.proxy_password,
):
_agent_log(
"C",
"src/planner_download.py:run_download_workflow",
"Browser failed to start",
{},
)
raise RuntimeError("Browser failed to start.")
_agent_log(
"C",
"src/planner_download.py:run_download_workflow",
"Browser started",
{},
)
driver = attach_browser(create_download_dir=False)
if driver is None:
_agent_log(
"C",
"src/planner_download.py:run_download_workflow",
"Browser attach failed",
{},
)
raise RuntimeError("Browser attach failed.")
log_step("Browser attached")
_log.info("Browser attached.")
consolidate_browser_to_single_tab(driver)
if cfg.proxy_username and cfg.proxy_password:
log_step("Dismiss proxy auth popup")
dismiss_proxy_auth_popup(
driver,
cfg.proxy_username.strip(),
str(cfg.proxy_password),
wait_secs=8.0,
max_attempts=3,
)
time.sleep(2)
log_step("Login and open Planner")
login_and_open_planner(
driver,
cfg.onedrive_username,
cfg.onedrive_password,
cfg.microsoft_totp_secret,
planner_url=cfg.planner_url,
)
log_url(driver, "after_login")
_close_blank_tabs(driver)
_agent_log(
"D",
"src/planner_download.py:run_download_workflow",
"Planner login returned",
{"url": (driver.current_url or "")[:160]},
)
results: dict[str, dict[str, object]] = {}
failures: list[tuple[str, str, str]] = []
total_files = 0
for idx, fac in enumerate(facilities, start=1):
if idx <= start_index:
continue
if fac in completed:
_log.info("Skipping already completed facility: %s", fac)
continue
log_step("Facility loop", index=f"{idx}/{len(facilities)}", facility=fac)
_log.info("Facility %d/%d: %s", idx, len(facilities), fac)
dest = original_root / _sanitize_folder_name(fac)
_agent_log(
"E",
"src/planner_download.py:facility_loop",
"Facility start",
{"index": idx, "facility": fac[:80]},
)
try:
facility_result: FacilityRunResult = process_facility(
driver,
fac,
dest,
screenshot_on_error=screenshot_on_error,
failure_log_dir=failure_log_dir if screenshot_on_error else None,
)
original_paths = facility_result.downloaded
file_count = len(original_paths)
comment = ""
if original_paths:
workable_paths, workable_root = _build_workable_files(
facility_name=fac,
downloaded=original_paths,
output_root=output_root,
today_iso=TODAY_ISO_DATE,
)
else:
workable_paths = []
workable_root = output_root / TODAY_ISO_DATE / "Workable"
comment = "0 files downloaded"
_remove_empty_directory(dest)
results[fac] = {
"original": original_paths,
"workable": workable_paths,
"comment": comment,
"tasks_total": facility_result.tasks_total,
"tasks_with_downloads": facility_result.tasks_with_downloads,
"tasks_with_attachments": facility_result.tasks_with_attachments,
"tasks_skipped_no_attachment": facility_result.tasks_skipped_no_attachment,
"status_changed": facility_result.status_changed,
"status_via_task_details": facility_result.status_via_task_details,
}
total_files += file_count
log_step(
"Facility complete",
facility=fac,
files=file_count,
tasks_with_downloads=str(facility_result.tasks_with_downloads),
status_changed=f"{facility_result.status_changed}/{facility_result.tasks_with_downloads}",
via_task_details=str(facility_result.status_via_task_details),
original=str(dest),
workable=str(workable_root),
)
_log.info(
"%s: %d original file(s), status %d/%d tasks (%d via task details) -> %s ; workable -> %s",
fac,
file_count,
facility_result.status_changed,
facility_result.tasks_with_downloads,
facility_result.status_via_task_details,
dest,
workable_root,
)
completed.add(fac)
log_step("Checkpoint saved", facility=fac, index=str(idx))
save_checkpoint(
checkpoint_path,
last_completed_index=idx,
facility_name=fac,
completed_facilities=sorted(completed),
)
except Exception as exc:
err_msg = str(exc)
status = _failure_status(exc)
log_step("Facility failed", facility=fac, error=err_msg[:200], status=status)
_log.error("%s: %s", fac, err_msg)
if screenshot_on_error and driver is not None:
save_failure_screenshot(driver, fac, failure_log_dir=failure_log_dir)
failures.append((fac, err_msg, status))
results[fac] = {
"original": [],
"workable": [],
"comment": err_msg[:500],
"tasks_total": 0,
"tasks_with_downloads": 0,
"tasks_with_attachments": 0,
"tasks_skipped_no_attachment": 0,
"status_changed": 0,
"status_via_task_details": 0,
}
save_checkpoint(
checkpoint_path,
last_completed_index=idx,
facility_name=fac,
completed_facilities=sorted(completed),
)
summary_path = (
PROJECT_ROOT / "Output" / TODAY_ISO_DATE / "Summary"
/ f"Download_Summary_{TODAY_ISO_DATE}.xlsx"
)
_write_download_summary(
results,
failures,
summary_path,
planner_url=cfg.planner_url,
run_timestamp=run_timestamp,
)
_prune_empty_date_output_dirs(output_root, TODAY_ISO_DATE)
clear_checkpoint(checkpoint_path)
log_step("Run complete", files=total_files, failures=len(failures), summary=str(summary_path))
_log.info("=" * 66)
_log.info(
" DOWNLOAD COMPLETE — %d file(s), %d failure(s)",
total_files,
len(failures),
)
_log.info("=" * 66)
except Exception as exc:
_log.critical("Run failed: %s", exc)
traceback.print_exc()
err_lower = str(exc).lower()
is_proxy_error = any(
kw in err_lower
for kw in ("proxy", "407", "auth", "intercepted", "credential", "popup")
)
if is_proxy_error or keep_browser:
user_msg = (
"An error occurred during OneDrive/Planner download.\n\n"
"The browser has been left open for inspection.\n\n"
f"Error:\n{str(exc)[:300]}"
)
dialog_title = "Download Error — Browser Left Open"
else:
user_msg = (
"An error occurred during OneDrive/Planner download.\n\n"
f"Error:\n{str(exc)[:300]}\n\n"
f"Details: Output\\{TODAY_ISO_DATE}\\logs\\download.log"
)
dialog_title = "Download Error"
_show_error_dialog(dialog_title, user_msg)
_log.info("PROCESS ENDED WITH ERROR")
_quit_browser(driver, keep_browser=keep_browser, is_proxy_error=is_proxy_error)
return
if not keep_browser:
if driver is not None:
_log.info("Closing browser...")
_quit_browser(driver, keep_browser=False)
driver = None
else:
_log.info("Browser left open (--keep-browser)")
_log.info("PROCESS ENDED")
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Download face-sheet PDFs from OneDrive / Planner.",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Process only the first N facilities (e.g. --limit 2 for a dry-run).",
)
parser.add_argument(
"--facility",
type=str,
default="",
help="Process a single facility by exact name (case-insensitive match).",
)
parser.add_argument(
"--resume",
action="store_true",
help="Resume from last completed facility (checkpoint file).",
)
parser.add_argument(
"--keep-browser",
action="store_true",
help="Do not close the browser when the run finishes.",
)
parser.add_argument(
"--screenshot",
action="store_true",
help="Save failure screenshots to Output/<date>/logs/failures/ only.",
)
parser.add_argument(
"--no-screenshot",
action="store_true",
help=argparse.SUPPRESS,
)
return parser
+40
View File
@@ -0,0 +1,40 @@
"""Step-by-step run logging for MISF automation (every major UI action)."""
from __future__ import annotations
import logging
from typing import Any
_RUN = logging.getLogger("misf.run")
def log_step(message: str, **fields: Any) -> None:
"""
Log one automation step. Use for every meaningful UI/browser action.
Examples:
log_step("Open My plans -> Shared")
log_step("Filter facility", facility="Amaya", rows=2)
"""
extras = []
for key, value in fields.items():
if value is None:
continue
text = str(value).strip()
if not text:
continue
# Avoid huge URLs in logs
if len(text) > 240:
text = text[:237] + "..."
extras.append(f"{key}={text}")
suffix = f" | {' | '.join(extras)}" if extras else ""
_RUN.info("[STEP] %s%s", message, suffix)
def log_url(driver, label: str = "current") -> None:
"""Log current browser URL when driver is available."""
try:
url = (driver.current_url or "")[:240]
if url:
log_step(f"URL ({label})", url=url)
except Exception:
pass
+33
View File
@@ -0,0 +1,33 @@
"""Microsoft Authenticator TOTP secret sanitization and validation."""
from __future__ import annotations
import re
_BASE32_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
def sanitize_totp_secret(raw: str) -> str:
"""Normalize Base32 secret: strip separators, uppercase, map 0→O and 1→I."""
secret = re.sub(r"[\s\-]", "", (raw or "").strip()).upper().rstrip("=")
return secret.translate(str.maketrans({"0": "O", "1": "I"}))
def invalid_totp_characters(secret: str) -> list[str]:
return sorted({c for c in secret if c not in _BASE32_CHARS})
def validate_totp_secret(raw: str) -> str:
"""
Return sanitized secret or raise ValueError with a clear message.
"""
sanitized = sanitize_totp_secret(raw)
if not sanitized:
raise ValueError("Microsoft TOTP Secret is empty (check input.xlsx or .env).")
invalid = invalid_totp_characters(sanitized)
if invalid:
raise ValueError(
"Microsoft TOTP Secret is not valid Base32. "
f"Invalid character(s): {', '.join(invalid[:10])}. "
"Fix the secret (Base32 uses A-Z and 2-7 only)."
)
return sanitized