Initial commit: MISF VOB download and upload automation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
c2dec96b1f
43 files changed
+10944
No files matched your search
@@ -0,0 +1 @@
|
||||
"""MISF OneDrive / Planner download automation."""
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user