Compare commits
10 Commits
ad0a0d07b2
...
1e5e4f436d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5e4f436d | ||
|
|
f03e44fd3c | ||
|
|
1ed54d6c15 | ||
|
|
cb0c4a512f | ||
|
|
518aa74dc4 | ||
|
|
31fce03012 | ||
|
|
1a9d6b2835 | ||
|
|
fab600fa07 | ||
|
|
539e1a916b | ||
|
|
9580a94e25 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -14,3 +14,6 @@ audit_*.md
|
|||||||
|
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Debug output
|
||||||
|
debug/
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 = []
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
3
start.sh
3
start.sh
@ -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 "$@"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user