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")
window = monitor.run()
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)
path = save_memo(memo_content)
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
print(f"\nMemo gespeichert: {path}")
print("\n" + memo_content)
memo_content = generate_memo(window)
path = save_memo(memo_content)
print(f"\nMemo gespeichert: {path}\n")
print(memo_content)
print("\n" + "" * 60 + "\n")
context.close()

View File

@ -1,27 +1,24 @@
import re
import sys
from datetime import datetime
from playwright.sync_api import Page
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']"
_PROFILE_EMAIL_SELECTOR = ".lpc_ip_root_class a[href*='mailto:']"
_START_RE = re.compile(r'^!start(?:\s+(.+))?$', re.IGNORECASE)
_STOP_RE = re.compile(r'^!stop$', re.IGNORECASE)
_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"""() => {
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; };
// Channel meeting: reply-message-header + message-body are siblings inside channel-pane-message
const channelPanes = Array.from(
document.querySelectorAll("[data-tid='channel-pane-message']")
).filter(isVisible);
@ -40,21 +37,18 @@ _POLL_JS = r"""() => {
senderIdx++;
} else if (tid === 'message-body' && currentSender) {
const text = el.innerText.trim();
if (text) {
msgs.push({
sender: currentSender,
text: text,
id: paneIdx + '_' + senderIdx + '_' + elIdx + '_' + text.substring(0, 20)
});
}
if (text) msgs.push({
sender: currentSender,
text: text,
id: paneIdx + '_' + senderIdx + '_' + elIdx + '_' + text.substring(0, 20)
});
}
});
});
return msgs;
}
// Meeting chat: message-author-name is a SIBLING of chat-pane-message, not a child.
// Walk both element types in document order, tracking current sender.
// Private meeting chat: message-author-name is a sibling of chat-pane-message
const chatEls = Array.from(document.querySelectorAll(
"[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');
} else {
let raw = el.innerText.trim();
if (currentSender && raw.startsWith(currentSender)) {
raw = raw.slice(currentSender.length);
}
if (currentSender && raw.startsWith(currentSender)) raw = raw.slice(currentSender.length);
raw = raw.replace(/^\s*\d{1,2}:\d{2}(?:\s*(?:AM|PM))?\s*/i, '').trim();
text = raw;
}
@ -83,22 +75,29 @@ _POLL_JS = r"""() => {
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:
if message.sender != current_user:
return None
text = message.text.strip()
m = _START_RE.match(text)
if m:
raw = (m.group(1) or "").strip(_QUOTE_CHARS).strip()
name = raw or "Unbekannter Vortragender"
return ("start", name)
return ("start", raw or "Unbekannter Vortragender")
if _STOP_RE.match(text):
return ("stop", "")
return None
@ -109,7 +108,7 @@ class Monitor:
self._seen_message_ids: set[str] = set()
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)
print("Chat erkannt.")
@ -132,36 +131,13 @@ class Monitor:
except Exception:
pass
self._page.wait_for_timeout(1000)
name = input("Benutzername nicht erkannt. Bitte deinen Teams-Anzeigenamen eingeben: ").strip()
name = input("Benutzername nicht erkannt. Bitte Teams-Anzeigenamen eingeben: ").strip()
if not name:
raise RuntimeError("Kein Benutzername angegeben.")
return name
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)
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 = []
for item in raw:
if item["id"] in self._seen_message_ids:
@ -175,7 +151,7 @@ class Monitor:
))
return new_messages
def run(self) -> AuditWindow:
def run_once(self) -> AuditWindow:
import time
self.wait_for_chat()
@ -183,27 +159,33 @@ class Monitor:
presenter: str | None = None
start_time: datetime | None = None
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:
current_chat = self._page.evaluate("""() => {
const t = document.querySelector(
"[data-tid='chat-title'], [data-tid='channelTitle-text'], [data-tid='entity-header'] h1"
);
return t ? t.innerText.trim() : document.title;
}""")
if current_chat != last_url:
print(f" [CHAT] {current_chat}")
last_url = current_chat
# Chat-Titel prüfen
try:
current_chat = self._page.evaluate(_CHAT_TITLE_JS)
if current_chat and current_chat != last_chat:
_clear_line()
print(f"Chat: {current_chat}")
last_chat = current_chat
except Exception:
pass
# Spinner anzeigen
spin = _SPINNER[spin_idx % len(_SPINNER)]
spin_idx += 1
sys.stdout.write(f'\r {spin}')
sys.stdout.flush()
try:
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:
print(f"Verbindung verloren ({type(e).__name__}: {e!s:.120}), warte auf Chat...")
_clear_line()
print(f"Verbindung unterbrochen, reconnecting...")
time.sleep(2)
try:
self.wait_for_chat()
@ -216,23 +198,26 @@ class Monitor:
if trigger is None:
if start_time is not None:
_clear_line()
print(f" {msg.sender}: {msg.text[:80]}")
collected.append(msg)
continue
action, value = trigger
_clear_line()
if action == "start":
presenter = value
start_time = datetime.now()
collected = []
print(f"Zeitfenster gestartet — Vortragender: {presenter}")
print(f"Zeitfenster gestartet — Vortragender: {presenter}")
elif action == "stop":
if start_time is None:
print("!stop ohne vorheriges !start — ignoriert.")
continue
end_time = datetime.now()
print("Zeitfenster beendet.")
print("Zeitfenster beendet.")
seen_senders = list(dict.fromkeys(m.sender for m in collected))
entries = [AuditEntry(display_name=s, email="") for s in seen_senders]
return AuditWindow(