feat: spinner, clean output, multi-cycle loop after !stop

This commit is contained in:
Oliver Hofmann 2026-05-17 14:15:11 +02:00
parent 537f5a52e5
commit 952606cf7e
2 changed files with 67 additions and 80 deletions

28
main.py
View File

@ -108,23 +108,25 @@ def main():
print("\nPoste '!start Name' im Chat um zu beginnen.\n") print("\nPoste '!start Name' im Chat um zu beginnen.\n")
window = monitor.run()
resolver = Resolver(cache_path=CACHE_PATH, page=page) resolver = Resolver(cache_path=CACHE_PATH, page=page)
print(f"\nLöse {len(window.entries)} E-Mail-Adresse(n) auf...")
resolved_entries = []
for entry in window.entries:
email = resolver.resolve(entry.display_name)
print(f" {entry.display_name}{email}")
resolved_entries.append(AuditEntry(display_name=entry.display_name, email=email))
window.entries = resolved_entries while True:
window = monitor.run_once()
memo_content = generate_memo(window) print(f"\nLöse {len(window.entries)} E-Mail-Adresse(n) auf...")
path = save_memo(memo_content) resolved_entries = []
for entry in window.entries:
email = resolver.resolve(entry.display_name)
print(f" {entry.display_name}{email}")
resolved_entries.append(AuditEntry(display_name=entry.display_name, email=email))
window.entries = resolved_entries
print(f"\nMemo gespeichert: {path}") memo_content = generate_memo(window)
print("\n" + memo_content) path = save_memo(memo_content)
print(f"\nMemo gespeichert: {path}\n")
print(memo_content)
print("\n" + "" * 60 + "\n")
context.close() context.close()

View File

@ -1,27 +1,24 @@
import re import re
import sys
from datetime import datetime from datetime import datetime
from playwright.sync_api import Page from playwright.sync_api import Page
from teampulse.models import AuditEntry, AuditWindow, ChatMessage from teampulse.models import AuditEntry, AuditWindow, ChatMessage
# Selectors verified against Teams Web DOM (2026-05-17)
# Supports both channel meetings (channel-pane-message) and meeting chat (chat-pane-message)
_MSG_SELECTOR = "[data-tid='channel-pane-message'], [data-tid='chat-pane-message']" _MSG_SELECTOR = "[data-tid='channel-pane-message'], [data-tid='chat-pane-message']"
_PROFILE_EMAIL_SELECTOR = ".lpc_ip_root_class a[href*='mailto:']" _PROFILE_EMAIL_SELECTOR = ".lpc_ip_root_class a[href*='mailto:']"
_START_RE = re.compile(r'^!start(?:\s+(.+))?$', re.IGNORECASE) _START_RE = re.compile(r'^!start(?:\s+(.+))?$', re.IGNORECASE)
_STOP_RE = re.compile(r'^!stop$', re.IGNORECASE) _STOP_RE = re.compile(r'^!stop$', re.IGNORECASE)
_QUOTE_CHARS = '"“”' # straight, left and right double quote _QUOTE_CHARS = '"“”' # straight, left and right double quote
_SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
# JavaScript that extracts (sender, text, id) from both channel and meeting chat DOM.
# Use raw string so \s, \d etc. pass through to JavaScript unchanged.
_POLL_JS = r"""() => { _POLL_JS = r"""() => {
const msgs = []; const msgs = [];
// Channel meeting: channel-pane-message contains reply-message-header + message-body siblings
// Filter to visible elements only using bounding rect (offsetParent fails with fixed layouts)
const isVisible = el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; const isVisible = el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
// Channel meeting: reply-message-header + message-body are siblings inside channel-pane-message
const channelPanes = Array.from( const channelPanes = Array.from(
document.querySelectorAll("[data-tid='channel-pane-message']") document.querySelectorAll("[data-tid='channel-pane-message']")
).filter(isVisible); ).filter(isVisible);
@ -40,21 +37,18 @@ _POLL_JS = r"""() => {
senderIdx++; senderIdx++;
} else if (tid === 'message-body' && currentSender) { } else if (tid === 'message-body' && currentSender) {
const text = el.innerText.trim(); const text = el.innerText.trim();
if (text) { if (text) msgs.push({
msgs.push({ sender: currentSender,
sender: currentSender, text: text,
text: text, id: paneIdx + '_' + senderIdx + '_' + elIdx + '_' + text.substring(0, 20)
id: paneIdx + '_' + senderIdx + '_' + elIdx + '_' + text.substring(0, 20) });
});
}
} }
}); });
}); });
return msgs; return msgs;
} }
// Meeting chat: message-author-name is a SIBLING of chat-pane-message, not a child. // Private meeting chat: message-author-name is a sibling of chat-pane-message
// Walk both element types in document order, tracking current sender.
const chatEls = Array.from(document.querySelectorAll( const chatEls = Array.from(document.querySelectorAll(
"[data-tid='message-author-name'], [data-tid='chat-pane-message']" "[data-tid='message-author-name'], [data-tid='chat-pane-message']"
)); ));
@ -70,9 +64,7 @@ _POLL_JS = r"""() => {
text = Array.from(pEls).map(p => p.innerText.trim()).filter(t => t).join('\n'); text = Array.from(pEls).map(p => p.innerText.trim()).filter(t => t).join('\n');
} else { } else {
let raw = el.innerText.trim(); let raw = el.innerText.trim();
if (currentSender && raw.startsWith(currentSender)) { if (currentSender && raw.startsWith(currentSender)) raw = raw.slice(currentSender.length);
raw = raw.slice(currentSender.length);
}
raw = raw.replace(/^\s*\d{1,2}:\d{2}(?:\s*(?:AM|PM))?\s*/i, '').trim(); raw = raw.replace(/^\s*\d{1,2}:\d{2}(?:\s*(?:AM|PM))?\s*/i, '').trim();
text = raw; text = raw;
} }
@ -83,22 +75,29 @@ _POLL_JS = r"""() => {
return msgs; return msgs;
}""" }"""
_CHAT_TITLE_JS = """() => {
const t = document.querySelector(
"[data-tid='chat-title'], [data-tid='channelTitle-text'], [data-tid='entity-header'] h1"
);
return t ? t.innerText.trim() : '';
}"""
def _clear_line() -> None:
sys.stdout.write('\r\033[K')
sys.stdout.flush()
def parse_trigger(message: ChatMessage, current_user: str) -> tuple[str, str] | None: def parse_trigger(message: ChatMessage, current_user: str) -> tuple[str, str] | None:
if message.sender != current_user: if message.sender != current_user:
return None return None
text = message.text.strip() text = message.text.strip()
m = _START_RE.match(text) m = _START_RE.match(text)
if m: if m:
raw = (m.group(1) or "").strip(_QUOTE_CHARS).strip() raw = (m.group(1) or "").strip(_QUOTE_CHARS).strip()
name = raw or "Unbekannter Vortragender" return ("start", raw or "Unbekannter Vortragender")
return ("start", name)
if _STOP_RE.match(text): if _STOP_RE.match(text):
return ("stop", "") return ("stop", "")
return None return None
@ -109,7 +108,7 @@ class Monitor:
self._seen_message_ids: set[str] = set() self._seen_message_ids: set[str] = set()
def wait_for_chat(self) -> None: def wait_for_chat(self) -> None:
print("Warte auf Teams-Chat-Seite... (bitte zur Meeting-Chat-Seite navigieren)") print("Warte auf Teams-Chat-Seite... (bitte zum Meeting-Chat navigieren)")
self._page.wait_for_selector(_MSG_SELECTOR, timeout=300_000) self._page.wait_for_selector(_MSG_SELECTOR, timeout=300_000)
print("Chat erkannt.") print("Chat erkannt.")
@ -132,36 +131,13 @@ class Monitor:
except Exception: except Exception:
pass pass
self._page.wait_for_timeout(1000) self._page.wait_for_timeout(1000)
name = input("Benutzername nicht erkannt. Bitte Teams-Anzeigenamen eingeben: ").strip()
name = input("Benutzername nicht erkannt. Bitte deinen Teams-Anzeigenamen eingeben: ").strip()
if not name: if not name:
raise RuntimeError("Kein Benutzername angegeben.") raise RuntimeError("Kein Benutzername angegeben.")
return name return name
def poll_new_messages(self) -> list[ChatMessage]: def poll_new_messages(self) -> list[ChatMessage]:
result = self._page.evaluate(r"""() => {
const isVisible = el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
const chatMsgs = Array.from(document.querySelectorAll("[data-tid='chat-pane-message']")).filter(isVisible);
const authorNames = Array.from(document.querySelectorAll("[data-tid='message-author-name']"));
const sample = chatMsgs.slice(0, 3).map(msg => ({
hasAuthorInside: !!msg.querySelector("[data-tid='message-author-name']"),
text: msg.innerText.trim().substring(0, 40),
}));
return {
channelAll: document.querySelectorAll("[data-tid='channel-pane-message']").length,
chatAll: document.querySelectorAll("[data-tid='chat-pane-message']").length,
channelVisible: Array.from(document.querySelectorAll("[data-tid='channel-pane-message']")).filter(isVisible).length,
chatVisible: chatMsgs.length,
authorNamesTotal: authorNames.length,
sample,
};
}""")
print(f" [DOM] channel={result['channelAll']}(vis:{result['channelVisible']}) chat={result['chatAll']}(vis:{result['chatVisible']}) authors={result['authorNamesTotal']}")
for s in result['sample']:
print(f" msg hasAuthor={s['hasAuthorInside']} text={s['text']!r}")
raw = self._page.evaluate(_POLL_JS) raw = self._page.evaluate(_POLL_JS)
if raw:
print(f" [POLL] {len(raw)} Elemente, davon {sum(1 for r in raw if r['id'] not in self._seen_message_ids)} neu")
new_messages = [] new_messages = []
for item in raw: for item in raw:
if item["id"] in self._seen_message_ids: if item["id"] in self._seen_message_ids:
@ -175,7 +151,7 @@ class Monitor:
)) ))
return new_messages return new_messages
def run(self) -> AuditWindow: def run_once(self) -> AuditWindow:
import time import time
self.wait_for_chat() self.wait_for_chat()
@ -183,27 +159,33 @@ class Monitor:
presenter: str | None = None presenter: str | None = None
start_time: datetime | None = None start_time: datetime | None = None
collected: list[ChatMessage] = [] collected: list[ChatMessage] = []
last_url: str = "" last_chat: str = ""
spin_idx: int = 0
print("Monitoring aktiv. Poste '!start Name' im Chat um ein Zeitfenster zu starten.") print("Bereit — poste '!start Name' im Chat um zu beginnen.")
while True: while True:
current_chat = self._page.evaluate("""() => { # Chat-Titel prüfen
const t = document.querySelector( try:
"[data-tid='chat-title'], [data-tid='channelTitle-text'], [data-tid='entity-header'] h1" current_chat = self._page.evaluate(_CHAT_TITLE_JS)
); if current_chat and current_chat != last_chat:
return t ? t.innerText.trim() : document.title; _clear_line()
}""") print(f"Chat: {current_chat}")
if current_chat != last_url: last_chat = current_chat
print(f" [CHAT] {current_chat}") except Exception:
last_url = current_chat pass
# Spinner anzeigen
spin = _SPINNER[spin_idx % len(_SPINNER)]
spin_idx += 1
sys.stdout.write(f'\r {spin}')
sys.stdout.flush()
try: try:
new_msgs = self.poll_new_messages() new_msgs = self.poll_new_messages()
for m in new_msgs:
print(f" [MSG] von='{m.sender}' text={m.text[:60]!r}")
except Exception as e: except Exception as e:
print(f"Verbindung verloren ({type(e).__name__}: {e!s:.120}), warte auf Chat...") _clear_line()
print(f"Verbindung unterbrochen, reconnecting...")
time.sleep(2) time.sleep(2)
try: try:
self.wait_for_chat() self.wait_for_chat()
@ -216,23 +198,26 @@ class Monitor:
if trigger is None: if trigger is None:
if start_time is not None: if start_time is not None:
_clear_line()
print(f" {msg.sender}: {msg.text[:80]}")
collected.append(msg) collected.append(msg)
continue continue
action, value = trigger action, value = trigger
_clear_line()
if action == "start": if action == "start":
presenter = value presenter = value
start_time = datetime.now() start_time = datetime.now()
collected = [] collected = []
print(f"Zeitfenster gestartet — Vortragender: {presenter}") print(f"Zeitfenster gestartet — Vortragender: {presenter}")
elif action == "stop": elif action == "stop":
if start_time is None: if start_time is None:
print("!stop ohne vorheriges !start — ignoriert.") print("!stop ohne vorheriges !start — ignoriert.")
continue continue
end_time = datetime.now() end_time = datetime.now()
print("Zeitfenster beendet.") print("Zeitfenster beendet.")
seen_senders = list(dict.fromkeys(m.sender for m in collected)) seen_senders = list(dict.fromkeys(m.sender for m in collected))
entries = [AuditEntry(display_name=s, email="") for s in seen_senders] entries = [AuditEntry(display_name=s, email="") for s in seen_senders]
return AuditWindow( return AuditWindow(