feat: spinner, clean output, multi-cycle loop after !stop
This commit is contained in:
parent
537f5a52e5
commit
952606cf7e
12
main.py
12
main.py
@ -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)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
window = monitor.run_once()
|
||||||
|
|
||||||
print(f"\nLöse {len(window.entries)} E-Mail-Adresse(n) auf...")
|
print(f"\nLöse {len(window.entries)} E-Mail-Adresse(n) auf...")
|
||||||
resolved_entries = []
|
resolved_entries = []
|
||||||
for entry in window.entries:
|
for entry in window.entries:
|
||||||
email = resolver.resolve(entry.display_name)
|
email = resolver.resolve(entry.display_name)
|
||||||
print(f" {entry.display_name} → {email}")
|
print(f" {entry.display_name} → {email}")
|
||||||
resolved_entries.append(AuditEntry(display_name=entry.display_name, email=email))
|
resolved_entries.append(AuditEntry(display_name=entry.display_name, email=email))
|
||||||
|
|
||||||
window.entries = resolved_entries
|
window.entries = resolved_entries
|
||||||
|
|
||||||
memo_content = generate_memo(window)
|
memo_content = generate_memo(window)
|
||||||
path = save_memo(memo_content)
|
path = save_memo(memo_content)
|
||||||
|
|
||||||
print(f"\nMemo gespeichert: {path}")
|
print(f"\nMemo gespeichert: {path}\n")
|
||||||
print("\n" + memo_content)
|
print(memo_content)
|
||||||
|
print("\n" + "─" * 60 + "\n")
|
||||||
|
|
||||||
context.close()
|
context.close()
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user