Compare commits

..

No commits in common. "1e5e4f436de3b69b84a17fb6919082c55b57edfc" and "ad0a0d07b2c5dbb5977081f17fb31510f8ceac23" have entirely different histories.

6 changed files with 137 additions and 150 deletions

3
.gitignore vendored
View File

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

View File

@ -33,39 +33,3 @@ 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, restore_window from teampulse.auth import create_context, ensure_logged_in
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,24 +57,17 @@ def main() -> None:
), ),
) )
parser.add_argument( parser.add_argument(
"-a", "--all", "--history",
action="store_true", action="store_true",
help="Alle Nachrichten auswerten inkl. bestehende (Standard: nur neue ab Scriptstart)", help="Bestehende Nachrichten auswerten (Standard: nur neue Nachrichten 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.all include_history: bool = args.history
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: {'alle' if include_history else 'nur neue'} Nachrichten") print(f" Modus: {'bestehende + neue' 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)
@ -116,15 +109,22 @@ 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, minimize=minimize) window = monitor.run_once(skip_existing=not include_history)
# 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 and minimize: if to_resolve:
restore_window(page) import sys as _sys
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,7 +4,6 @@ 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']"
@ -152,7 +151,7 @@ class Monitor:
)) ))
return new_messages return new_messages
def run_once(self, skip_existing: bool = True, minimize: bool = True) -> AuditWindow: def run_once(self, skip_existing: bool = True) -> AuditWindow:
import time import time
self.wait_for_chat() self.wait_for_chat()
@ -231,8 +230,6 @@ 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,6 +4,17 @@ 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):
@ -34,132 +45,153 @@ class Resolver:
encoding="utf-8", encoding="utf-8",
) )
def _click_avatar(self, display_name: str) -> str: def _hover_sender(self, display_name: str) -> tuple[str, dict]:
"""Find the sender's avatar and click it to open the profile dialog. """Find the sender element and hover to open the profile card.
Returns strategy name or '' if not found.""" Returns (strategy, bbox) or ('', {}) if not found."""
def _click_el(el) -> bool: def _mouse_move_to(el) -> dict | None:
try: """Scroll element into view, get its bbox, move mouse there."""
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 False return None
el.click() x = bbox['x'] + bbox['width'] / 2
return True y = bbox['y'] + bbox['height'] / 2
except Exception: self._page.mouse.move(x, y)
return False return bbox
# Strategy 1: reply-message-header (channel meetings) — click avatar # Strategy 1: reply-message-header (channel meetings)
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 and _click_el(avatar): if avatar:
return "reply-avatar" bbox = _mouse_move_to(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 and _click_el(span): if span.inner_text().strip() == display_name:
return "reply-span" bbox = _mouse_move_to(span)
if bbox:
return "reply-span", bbox
except Exception: except Exception:
continue continue
# Strategy 2: message-author-name — find avatar in parent tree and click # Strategy 2: aria-label
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
# Find avatar div in parent tree via JS and click it # Look for avatar in parent tree (usually 3-4 levels up)
clicked = self._page.evaluate("""(nameEl) => { avatar_bbox = 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('[class*="ChatMessage__avatar"], [class*="avatar"]'); const av = p.querySelector('[data-tid*="avatar"], [class*="avatar"] img, [class*="Avatar"] img');
if (av) { if (av) {
const inner = av.querySelector('img, [role="img"], button') || av; const r = av.getBoundingClientRect();
const r = inner.getBoundingClientRect(); if (r.width > 0) return {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width, h: r.height};
if (r.width > 0) { inner.click(); return true; }
} }
p = p.parentElement; p = p.parentElement;
} }
return false; return null;
}""", el) }""", el)
if clicked: if avatar_bbox:
return "nearby-avatar" self._page.mouse.move(avatar_bbox['x'], avatar_bbox['y'])
# Fallback: click the name span return "nearby-avatar", avatar_bbox
if _click_el(el): bbox = _mouse_move_to(el)
return "author-name" if bbox:
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 = self._click_avatar(display_name) strategy, bbox = self._hover_sender(display_name)
except Exception as e: except Exception as e:
print(f" Klick auf '{display_name}' fehlgeschlagen: {e}") print(f" Hover 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...")
print(f" '{display_name}' via '{strategy}' geklickt — warte auf Dialog...") # Screenshot right after hover for debugging
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
# Do NOT poll — Playwright's JS polling blocks Teams' async dialog loading. time.sleep(0.5) # Give Teams time to register the hover
time.sleep(3)
try: try:
email = self._page.evaluate(r"""() => { # Stage 1: wait for card container to appear
const card = document.querySelector('.lpc_ip_root_class lpc-card'); self._page.wait_for_selector(".lpc_ip_root_class", timeout=5_000)
if (!card) return '__NO_CARD__'; except Exception:
print(f" Hover-Card für '{display_name}' erscheint nicht.")
self._page.mouse.move(0, 0)
return None
// 1. mailto link (most reliable recurses into nested shadow DOMs) try:
function findMailto(root) { # Move mouse onto the card itself to keep it open while it loads
const link = root.querySelector('a[href*="mailto:"]'); card_el = self._page.query_selector(".lpc_ip_root_class")
if (link) return link.href.replace('mailto:', '').trim(); if card_el:
for (const el of root.querySelectorAll('*')) { card_el.hover()
if (el.shadowRoot) {
const found = findMailto(el.shadowRoot);
if (found) return found;
}
}
return null;
}
const linked = findMailto(card);
if (linked) return linked;
// 2. Extract email from text after common label patterns # Stage 2: card content is inside a Shadow DOM (<lpc-card shadowrootmode="open">)
// Teams dialog text is concatenated without spaces, e.g.: # Wait for the mailto link to appear inside the shadow root
// "SieKontaktinformationenE-Mailanja@example.com" self._page.wait_for_function(
const text = card.textContent; """() => {
for (const label of ['E-Mail', 'E-mail', 'Email', 'email', 'mail', 'Mail']) { const lpcCard = document.querySelector('.lpc_ip_root_class lpc-card');
const idx = text.indexOf(label); if (!lpcCard || !lpcCard.shadowRoot) return false;
if (idx !== -1) { return !!lpcCard.shadowRoot.querySelector('a[href*="mailto:"]');
const after = text.substring(idx + label.length); }""",
const m = after.match(/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/); timeout=10_000,
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;
return '__NO_EMAIL__' + text.trim().substring(0, 200); const link = lpcCard.shadowRoot.querySelector('a[href*="mailto:"]');
return link ? link.href.replace('mailto:', '').trim() : null;
}""") }""")
self._page.mouse.move(0, 0)
if not email or email == '__NO_CARD__':
print(f" Kein Profil-Dialog erschienen.")
self._page.keyboard.press("Escape")
return None
if email.startswith('__NO_EMAIL__'):
print(f" Dialog offen, E-Mail nicht gefunden. Inhalt: {email[12:150]!r}")
self._page.keyboard.press("Escape")
return None
self._page.keyboard.press("Escape")
time.sleep(0.3) time.sleep(0.3)
return email return email or None
except Exception:
except Exception as e: # Screenshot + shadow root text for diagnosis
print(f" Fehler beim Lesen des Profil-Dialogs: {e}") try:
self._page.keyboard.press("Escape") 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}_card_state.png"))
except Exception:
pass
card_text = self._page.evaluate("""() => {
const lpcCard = document.querySelector('.lpc_ip_root_class lpc-card');
if (!lpcCard) return '(kein lpc-card Element)';
if (!lpcCard.shadowRoot) return '(kein Shadow Root)';
return lpcCard.shadowRoot.textContent.trim().substring(0, 300);
}""") or "(leer)"
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,7 +9,4 @@ 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 "$@"