Compare commits

...

10 Commits

6 changed files with 146 additions and 133 deletions

3
.gitignore vendored
View File

@ -14,3 +14,6 @@ audit_*.md
# Claude Code # Claude Code
.claude/ .claude/
# Debug output
debug/

View File

@ -33,3 +33,39 @@ def _is_login_page(page) -> bool:
return True return True
except Exception: except Exception:
return False return False
def _get_window_id(page) -> int | None:
try:
client = page.context.new_cdp_session(page)
info = client.send("Target.getTargetInfo")
tid = info["targetInfo"]["targetId"]
result = client.send("Browser.getWindowForTarget", {"targetId": tid})
client.detach()
return result["windowId"]
except Exception:
return None
def minimize_window(page) -> None:
wid = _get_window_id(page)
if wid is None:
return
try:
client = page.context.new_cdp_session(page)
client.send("Browser.setWindowBounds", {"windowId": wid, "bounds": {"windowState": "minimized"}})
client.detach()
except Exception:
pass
def restore_window(page) -> None:
wid = _get_window_id(page)
if wid is None:
return
try:
client = page.context.new_cdp_session(page)
client.send("Browser.setWindowBounds", {"windowId": wid, "bounds": {"windowState": "normal"}})
client.detach()
except Exception:
pass

View File

@ -9,7 +9,7 @@ from pathlib import Path
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
from teampulse.auth import create_context, ensure_logged_in from teampulse.auth import create_context, ensure_logged_in, restore_window
from teampulse.memo import generate_memo, save_memo from teampulse.memo import generate_memo, save_memo
from teampulse.models import AuditEntry from teampulse.models import AuditEntry
from teampulse.monitor import Monitor from teampulse.monitor import Monitor
@ -57,17 +57,24 @@ def main() -> None:
), ),
) )
parser.add_argument( parser.add_argument(
"--history", "-a", "--all",
action="store_true", action="store_true",
help="Bestehende Nachrichten auswerten (Standard: nur neue Nachrichten ab Scriptstart)", help="Alle Nachrichten auswerten inkl. bestehende (Standard: nur neue ab Scriptstart)",
)
parser.add_argument(
"--no-minimize",
action="store_true",
help="Browser-Fenster nicht minimieren während der Aufzeichnung",
) )
args = parser.parse_args() args = parser.parse_args()
include_history: bool = args.history include_history: bool = args.all
minimize: bool = not args.no_minimize
print("" * 60) print("" * 60)
print(" TeamPulse — Teams Chat Auswertung") print(" TeamPulse — Teams Chat Auswertung")
print("" * 60) print("" * 60)
print(f" Modus: {'bestehende + neue' if include_history else 'nur neue'} Nachrichten") print(f" Modus: {'alle' if include_history else 'nur neue'} Nachrichten")
print(f" Browser: {'sichtbar' if not minimize else 'minimiert während Aufzeichnung'}")
print(" Trigger: !start Name | !stop") print(" Trigger: !start Name | !stop")
print(" Beenden: Ctrl+C") print(" Beenden: Ctrl+C")
print("" * 60) print("" * 60)
@ -109,22 +116,15 @@ def main() -> None:
resolver = Resolver(cache_path=CACHE_PATH, page=page) resolver = Resolver(cache_path=CACHE_PATH, page=page)
while True: while True:
window = monitor.run_once(skip_existing=not include_history) window = monitor.run_once(skip_existing=not include_history, minimize=minimize)
# Only resolve entries that appear in the final memo # Only resolve entries that appear in the final memo
# (moderator and presenter are excluded — no need to look up their email) # (moderator and presenter are excluded — no need to look up their email)
excluded = {window.moderator, window.presenter} excluded = {window.moderator, window.presenter}
to_resolve = [e for e in window.entries if e.display_name not in excluded] to_resolve = [e for e in window.entries if e.display_name not in excluded]
if to_resolve: if to_resolve and minimize:
import sys as _sys restore_window(page)
for i in range(3, 0, -1):
_sys.stdout.write(f"\r Maus aus Browser-Fenster bewegen! Starte in {i}s...")
_sys.stdout.flush()
import time as _time; _time.sleep(1)
_sys.stdout.write("\r\033[K")
# Move Playwright mouse to top-left corner before hovering
page.mouse.move(0, 0)
print(f"Löse {len(to_resolve)} E-Mail-Adresse(n) auf...") print(f"Löse {len(to_resolve)} E-Mail-Adresse(n) auf...")
resolved_entries = [] resolved_entries = []

View File

@ -4,6 +4,7 @@ from datetime import datetime
from playwright.sync_api import Page from playwright.sync_api import Page
from teampulse.auth import minimize_window
from teampulse.models import AuditEntry, AuditWindow, ChatMessage from teampulse.models import AuditEntry, AuditWindow, ChatMessage
_MSG_SELECTOR = "[data-tid='channel-pane-message'], [data-tid='chat-pane-message']" _MSG_SELECTOR = "[data-tid='channel-pane-message'], [data-tid='chat-pane-message']"
@ -151,7 +152,7 @@ class Monitor:
)) ))
return new_messages return new_messages
def run_once(self, skip_existing: bool = True) -> AuditWindow: def run_once(self, skip_existing: bool = True, minimize: bool = True) -> AuditWindow:
import time import time
self.wait_for_chat() self.wait_for_chat()
@ -230,6 +231,8 @@ class Monitor:
start_time = datetime.now() start_time = datetime.now()
collected = [] collected = []
print(f"▶ Zeitfenster gestartet — Vortragender: {presenter}") print(f"▶ Zeitfenster gestartet — Vortragender: {presenter}")
if minimize:
minimize_window(self._page)
elif action == "stop": elif action == "stop":
if start_time is None: if start_time is None:

View File

@ -4,17 +4,6 @@ from pathlib import Path
from playwright.sync_api import Page from playwright.sync_api import Page
from teampulse.monitor import _PROFILE_EMAIL_SELECTOR
# Selectors for the clickable sender element (avatar or name) that opens the profile card
_AUTHOR_SELECTORS = [
# Channel meeting: avatar next to reply-message-header name
"[data-tid='reply-message-header-avatar']",
"[data-tid='post-message-header-avatar']",
# Meeting chat: author name element
"[data-tid='message-author-name']",
]
class Resolver: class Resolver:
def __init__(self, cache_path: Path, page: Page): def __init__(self, cache_path: Path, page: Page):
@ -45,153 +34,132 @@ class Resolver:
encoding="utf-8", encoding="utf-8",
) )
def _hover_sender(self, display_name: str) -> tuple[str, dict]: def _click_avatar(self, display_name: str) -> str:
"""Find the sender element and hover to open the profile card. """Find the sender's avatar and click it to open the profile dialog.
Returns (strategy, bbox) or ('', {}) if not found.""" Returns strategy name or '' if not found."""
def _mouse_move_to(el) -> dict | None: def _click_el(el) -> bool:
"""Scroll element into view, get its bbox, move mouse there.""" try:
el.scroll_into_view_if_needed() el.scroll_into_view_if_needed()
bbox = el.bounding_box() bbox = el.bounding_box()
if not bbox or bbox['width'] == 0: if not bbox or bbox['width'] == 0:
return None return False
x = bbox['x'] + bbox['width'] / 2 el.click()
y = bbox['y'] + bbox['height'] / 2 return True
self._page.mouse.move(x, y) except Exception:
return bbox return False
# Strategy 1: reply-message-header (channel meetings) # Strategy 1: reply-message-header (channel meetings) — click avatar
for header in self._page.query_selector_all("[data-tid='reply-message-header']"): for header in self._page.query_selector_all("[data-tid='reply-message-header']"):
try: try:
if display_name not in header.inner_text(): if display_name not in header.inner_text():
continue continue
avatar = header.query_selector("[data-tid='reply-message-header-avatar']") avatar = header.query_selector("[data-tid='reply-message-header-avatar']")
if avatar: if avatar and _click_el(avatar):
bbox = _mouse_move_to(avatar) return "reply-avatar"
if bbox:
return "reply-avatar", bbox
for span in header.query_selector_all("span"): for span in header.query_selector_all("span"):
if span.inner_text().strip() == display_name: if span.inner_text().strip() == display_name and _click_el(span):
bbox = _mouse_move_to(span) return "reply-span"
if bbox:
return "reply-span", bbox
except Exception: except Exception:
continue continue
# Strategy 2: aria-label # Strategy 2: message-author-name — find avatar in parent tree and click
safe_name = display_name.replace("'", "\\'")
for el in self._page.query_selector_all(f"[aria-label*='{safe_name}']"):
try:
if el.is_visible():
bbox = _mouse_move_to(el)
if bbox:
return "aria-label", bbox
except Exception:
continue
# Strategy 3: message-author-name — also try avatar to the left
for el in self._page.query_selector_all("[data-tid='message-author-name']"): for el in self._page.query_selector_all("[data-tid='message-author-name']"):
try: try:
if el.inner_text().strip() != display_name: if el.inner_text().strip() != display_name:
continue continue
# Look for avatar in parent tree (usually 3-4 levels up) # Find avatar div in parent tree via JS and click it
avatar_bbox = self._page.evaluate("""(nameEl) => { clicked = self._page.evaluate("""(nameEl) => {
let p = nameEl.parentElement; let p = nameEl.parentElement;
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
if (!p) break; if (!p) break;
const av = p.querySelector('[data-tid*="avatar"], [class*="avatar"] img, [class*="Avatar"] img'); const av = p.querySelector('[class*="ChatMessage__avatar"], [class*="avatar"]');
if (av) { if (av) {
const r = av.getBoundingClientRect(); const inner = av.querySelector('img, [role="img"], button') || av;
if (r.width > 0) return {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width, h: r.height}; const r = inner.getBoundingClientRect();
if (r.width > 0) { inner.click(); return true; }
} }
p = p.parentElement; p = p.parentElement;
} }
return null; return false;
}""", el) }""", el)
if avatar_bbox: if clicked:
self._page.mouse.move(avatar_bbox['x'], avatar_bbox['y']) return "nearby-avatar"
return "nearby-avatar", avatar_bbox # Fallback: click the name span
bbox = _mouse_move_to(el) if _click_el(el):
if bbox: return "author-name"
return "author-name", bbox
except Exception: except Exception:
continue continue
return "", {} return ""
def _extract_email_from_profile(self, display_name: str) -> str | None: def _extract_email_from_profile(self, display_name: str) -> str | None:
try: try:
strategy, bbox = self._hover_sender(display_name) strategy = self._click_avatar(display_name)
except Exception as e: except Exception as e:
print(f" Hover auf '{display_name}' fehlgeschlagen: {e}") print(f" Klick auf '{display_name}' fehlgeschlagen: {e}")
return None return None
if not strategy: if not strategy:
print(f" '{display_name}' nicht im sichtbaren Chat gefunden.") print(f" '{display_name}' nicht im sichtbaren Chat gefunden.")
return None return None
print(f" '{display_name}' via '{strategy}' bei {bbox} — warte auf Karte...")
# Screenshot right after hover for debugging print(f" '{display_name}' via '{strategy}' geklickt — warte auf Dialog...")
try:
from pathlib import Path as _Path
debug_dir = _Path("debug"); debug_dir.mkdir(exist_ok=True)
safe = display_name.replace(" ", "_")
self._page.screenshot(path=str(debug_dir / f"{safe}_after_hover.png"))
except Exception:
pass
time.sleep(0.5) # Give Teams time to register the hover # Do NOT poll — Playwright's JS polling blocks Teams' async dialog loading.
time.sleep(3)
try: try:
# Stage 1: wait for card container to appear email = self._page.evaluate(r"""() => {
self._page.wait_for_selector(".lpc_ip_root_class", timeout=5_000) const card = document.querySelector('.lpc_ip_root_class lpc-card');
except Exception: if (!card) return '__NO_CARD__';
print(f" Hover-Card für '{display_name}' erscheint nicht.")
self._page.mouse.move(0, 0)
return None
try: // 1. mailto link (most reliable recurses into nested shadow DOMs)
# Move mouse onto the card itself to keep it open while it loads function findMailto(root) {
card_el = self._page.query_selector(".lpc_ip_root_class") const link = root.querySelector('a[href*="mailto:"]');
if card_el: if (link) return link.href.replace('mailto:', '').trim();
card_el.hover() for (const el of root.querySelectorAll('*')) {
if (el.shadowRoot) {
const found = findMailto(el.shadowRoot);
if (found) return found;
}
}
return null;
}
const linked = findMailto(card);
if (linked) return linked;
# Stage 2: card content is inside a Shadow DOM (<lpc-card shadowrootmode="open">) // 2. Extract email from text after common label patterns
# Wait for the mailto link to appear inside the shadow root // Teams dialog text is concatenated without spaces, e.g.:
self._page.wait_for_function( // "SieKontaktinformationenE-Mailanja@example.com"
"""() => { const text = card.textContent;
const lpcCard = document.querySelector('.lpc_ip_root_class lpc-card'); for (const label of ['E-Mail', 'E-mail', 'Email', 'email', 'mail', 'Mail']) {
if (!lpcCard || !lpcCard.shadowRoot) return false; const idx = text.indexOf(label);
return !!lpcCard.shadowRoot.querySelector('a[href*="mailto:"]'); if (idx !== -1) {
}""", const after = text.substring(idx + label.length);
timeout=10_000, const m = after.match(/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/);
) if (m) return m[0];
email = self._page.evaluate("""() => { }
const lpcCard = document.querySelector('.lpc_ip_root_class lpc-card'); }
if (!lpcCard || !lpcCard.shadowRoot) return null;
const link = lpcCard.shadowRoot.querySelector('a[href*="mailto:"]'); return '__NO_EMAIL__' + text.trim().substring(0, 200);
return link ? link.href.replace('mailto:', '').trim() : null;
}""") }""")
self._page.mouse.move(0, 0)
time.sleep(0.3) if not email or email == '__NO_CARD__':
return email or None print(f" Kein Profil-Dialog erschienen.")
except Exception: self._page.keyboard.press("Escape")
# Screenshot + shadow root text for diagnosis return None
try:
from pathlib import Path as _Path if email.startswith('__NO_EMAIL__'):
debug_dir = _Path("debug"); debug_dir.mkdir(exist_ok=True) print(f" Dialog offen, E-Mail nicht gefunden. Inhalt: {email[12:150]!r}")
safe = display_name.replace(" ", "_") self._page.keyboard.press("Escape")
self._page.screenshot(path=str(debug_dir / f"{safe}_card_state.png")) return None
except Exception:
pass self._page.keyboard.press("Escape")
card_text = self._page.evaluate("""() => { time.sleep(0.3)
const lpcCard = document.querySelector('.lpc_ip_root_class lpc-card'); return email
if (!lpcCard) return '(kein lpc-card Element)';
if (!lpcCard.shadowRoot) return '(kein Shadow Root)'; except Exception as e:
return lpcCard.shadowRoot.textContent.trim().substring(0, 300); print(f" Fehler beim Lesen des Profil-Dialogs: {e}")
}""") or "(leer)" self._page.keyboard.press("Escape")
print(f" Karte für '{display_name}' offen, E-Mail nicht gefunden.")
print(f" Shadow-Root-Inhalt: {card_text!r}")
print(f" Screenshots in debug/{safe}_*.png")
self._page.mouse.move(0, 0)
return None return None

View File

@ -9,4 +9,7 @@ if [ ! -d ".venv" ]; then
.venv/bin/playwright install chromium .venv/bin/playwright install chromium
fi fi
# Clear stale bytecode before each run to avoid executing outdated .pyc files
find src -name "*.pyc" -delete 2>/dev/null || true
PYTHONDONTWRITEBYTECODE=1 .venv/bin/python main.py "$@" PYTHONDONTWRITEBYTECODE=1 .venv/bin/python main.py "$@"