teams-transcripts 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- legacy/__init__.py +0 -0
- legacy/transcript_download.py +3969 -0
- teams_transcripts/__init__.py +1 -0
- teams_transcripts/__main__.py +288 -0
- teams_transcripts/adapters/__init__.py +1 -0
- teams_transcripts/adapters/cdp/__init__.py +1 -0
- teams_transcripts/adapters/cdp/browser.py +196 -0
- teams_transcripts/adapters/cdp/connection.py +168 -0
- teams_transcripts/adapters/cdp/helpers.py +449 -0
- teams_transcripts/adapters/cdp/teams.py +463 -0
- teams_transcripts/adapters/vertex.py +274 -0
- teams_transcripts/domain/__init__.py +1 -0
- teams_transcripts/domain/chat.py +115 -0
- teams_transcripts/domain/html.py +126 -0
- teams_transcripts/domain/mcps.py +343 -0
- teams_transcripts/domain/models.py +531 -0
- teams_transcripts/domain/participants.py +292 -0
- teams_transcripts/domain/scoring.py +106 -0
- teams_transcripts/domain/speakers.py +130 -0
- teams_transcripts/domain/summary.py +266 -0
- teams_transcripts/domain/timestamps.py +350 -0
- teams_transcripts/domain/vtt.py +444 -0
- teams_transcripts/infrastructure/__init__.py +1 -0
- teams_transcripts/infrastructure/config.py +987 -0
- teams_transcripts/infrastructure/errors.py +120 -0
- teams_transcripts/infrastructure/logging.py +69 -0
- teams_transcripts/infrastructure/signals.py +143 -0
- teams_transcripts/mcp_server.py +818 -0
- teams_transcripts/ports/__init__.py +1 -0
- teams_transcripts/ports/agent.py +58 -0
- teams_transcripts/ports/browser.py +126 -0
- teams_transcripts/services/__init__.py +1 -0
- teams_transcripts/services/downloader.py +1283 -0
- teams_transcripts/services/extraction.py +1603 -0
- teams_transcripts/services/listing.py +826 -0
- teams_transcripts/services/navigation.py +1721 -0
- teams_transcripts/services/output.py +84 -0
- teams_transcripts/services/tenant.py +684 -0
- teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
- teams_transcripts-0.5.0.dist-info/RECORD +42 -0
- teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
- teams_transcripts-0.5.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Vertex AI agent adapter -- AnthropicVertex client and query functions.
|
|
2
|
+
|
|
3
|
+
Extracted from ``transcript_download.py`` lines 656-664
|
|
4
|
+
(``create_agent_client``), 667-698 (``agent_vision_query``), and
|
|
5
|
+
701-716 (``agent_text_query``).
|
|
6
|
+
|
|
7
|
+
Provides both standalone functions (used by helpers like
|
|
8
|
+
``find_iframe_target``) and a ``VertexAgentAdapter`` class that
|
|
9
|
+
implements the ``AgentPort`` protocol for service-layer injection.
|
|
10
|
+
|
|
11
|
+
Schema version: 1.0.0
|
|
12
|
+
Created: 2026-04-25
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import typing
|
|
21
|
+
|
|
22
|
+
if typing.TYPE_CHECKING:
|
|
23
|
+
from teams_transcripts.domain.models import MeetingConfig
|
|
24
|
+
from teams_transcripts.infrastructure.logging import Logger
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# JSON extraction helper
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def extract_json_from_response(text: str) -> dict | None:
|
|
33
|
+
"""Extract first JSON object from response text.
|
|
34
|
+
|
|
35
|
+
Handles plain JSON, markdown-fenced JSON, or JSON embedded in prose.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
text: Raw response text from the LLM.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Parsed JSON dict, or ``None`` if no valid JSON found.
|
|
42
|
+
"""
|
|
43
|
+
if not text:
|
|
44
|
+
return None
|
|
45
|
+
json_match = re.search(r"\{[^}]+\}", text)
|
|
46
|
+
if json_match:
|
|
47
|
+
try:
|
|
48
|
+
return json.loads(json_match.group())
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
return None
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Standalone query functions (used by helpers.find_iframe_target etc.)
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def agent_text_query(
|
|
60
|
+
client: typing.Any,
|
|
61
|
+
model: str,
|
|
62
|
+
prompt: str,
|
|
63
|
+
*,
|
|
64
|
+
logger: Logger | None = None,
|
|
65
|
+
) -> dict | None:
|
|
66
|
+
"""Send a single-turn text query to the agent.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
client: AnthropicVertex client instance.
|
|
70
|
+
model: Model identifier.
|
|
71
|
+
prompt: Text prompt.
|
|
72
|
+
logger: Optional logger for error reporting.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Parsed JSON dict, or ``None`` on failure.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
response = client.messages.create(
|
|
79
|
+
model=model,
|
|
80
|
+
max_tokens=512,
|
|
81
|
+
messages=[{"role": "user", "content": prompt}],
|
|
82
|
+
)
|
|
83
|
+
text = response.content[0].text.strip()
|
|
84
|
+
return extract_json_from_response(text)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
if logger is not None:
|
|
87
|
+
logger.log("agent", f"Text query failed: {exc}")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def agent_vision_query(
|
|
92
|
+
client: typing.Any,
|
|
93
|
+
model: str,
|
|
94
|
+
image_b64: str,
|
|
95
|
+
prompt: str,
|
|
96
|
+
*,
|
|
97
|
+
logger: Logger | None = None,
|
|
98
|
+
) -> dict | None:
|
|
99
|
+
"""Send a single-turn vision query to the agent.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
client: AnthropicVertex client instance.
|
|
103
|
+
model: Model identifier.
|
|
104
|
+
image_b64: Base64-encoded PNG image data.
|
|
105
|
+
prompt: Text prompt accompanying the image.
|
|
106
|
+
logger: Optional logger for error reporting.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Parsed JSON dict, or ``None`` on failure.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
response = client.messages.create(
|
|
113
|
+
model=model,
|
|
114
|
+
max_tokens=512,
|
|
115
|
+
messages=[
|
|
116
|
+
{
|
|
117
|
+
"role": "user",
|
|
118
|
+
"content": [
|
|
119
|
+
{
|
|
120
|
+
"type": "image",
|
|
121
|
+
"source": {
|
|
122
|
+
"type": "base64",
|
|
123
|
+
"media_type": "image/png",
|
|
124
|
+
"data": image_b64,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{"type": "text", "text": prompt},
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
text = response.content[0].text.strip()
|
|
133
|
+
return extract_json_from_response(text)
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
if logger is not None:
|
|
136
|
+
logger.log("agent", f"Vision query failed: {exc}")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Client factory
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def create_agent_client(config: MeetingConfig) -> typing.Any:
|
|
146
|
+
"""Create an AnthropicVertex client from resolved config.
|
|
147
|
+
|
|
148
|
+
Sets ``GOOGLE_APPLICATION_CREDENTIALS`` in the environment and
|
|
149
|
+
returns a configured client instance.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
config: Meeting configuration with ``adc``, ``gcp_project``,
|
|
153
|
+
and ``gcp_region`` fields.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
An ``AnthropicVertex`` client instance.
|
|
157
|
+
"""
|
|
158
|
+
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = config.adc
|
|
159
|
+
from anthropic.lib.vertex import AnthropicVertex
|
|
160
|
+
|
|
161
|
+
return AnthropicVertex(
|
|
162
|
+
project_id=config.gcp_project,
|
|
163
|
+
region=config.gcp_region,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Lazy import removed -- AnthropicVertex imported at call site above
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# VertexAgentAdapter (implements AgentPort)
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class VertexAgentAdapter:
|
|
178
|
+
"""AgentPort implementation using Anthropic SDK via Vertex AI.
|
|
179
|
+
|
|
180
|
+
Wraps an AnthropicVertex client and model identifier, delegating
|
|
181
|
+
to the standalone query functions.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
client: AnthropicVertex client instance.
|
|
185
|
+
model: Model identifier (e.g. ``"claude-haiku-4-5"``).
|
|
186
|
+
logger: Logger instance for error reporting.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
*,
|
|
192
|
+
client: typing.Any,
|
|
193
|
+
model: str,
|
|
194
|
+
logger: Logger,
|
|
195
|
+
) -> None:
|
|
196
|
+
self._client = client
|
|
197
|
+
self._model = model
|
|
198
|
+
self._logger = logger
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def client(self) -> typing.Any:
|
|
202
|
+
"""The underlying AnthropicVertex client instance."""
|
|
203
|
+
return self._client
|
|
204
|
+
|
|
205
|
+
def text_query(
|
|
206
|
+
self,
|
|
207
|
+
prompt: str,
|
|
208
|
+
*,
|
|
209
|
+
max_tokens: int = 512,
|
|
210
|
+
) -> dict | None:
|
|
211
|
+
"""Send a text-only prompt to the agent.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
prompt: Text prompt.
|
|
215
|
+
max_tokens: Maximum tokens in the response.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Parsed JSON dict, or ``None`` on failure.
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
response = self._client.messages.create(
|
|
222
|
+
model=self._model,
|
|
223
|
+
max_tokens=max_tokens,
|
|
224
|
+
messages=[{"role": "user", "content": prompt}],
|
|
225
|
+
)
|
|
226
|
+
text = response.content[0].text.strip()
|
|
227
|
+
return extract_json_from_response(text)
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
self._logger.log("agent", f"Text query failed: {exc}")
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def vision_query(
|
|
233
|
+
self,
|
|
234
|
+
image_b64: str,
|
|
235
|
+
prompt: str,
|
|
236
|
+
*,
|
|
237
|
+
max_tokens: int = 512,
|
|
238
|
+
) -> dict | None:
|
|
239
|
+
"""Send an image + text prompt to the agent.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
image_b64: Base64-encoded PNG image data.
|
|
243
|
+
prompt: Text prompt.
|
|
244
|
+
max_tokens: Maximum tokens in the response.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Parsed JSON dict, or ``None`` on failure.
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
response = self._client.messages.create(
|
|
251
|
+
model=self._model,
|
|
252
|
+
max_tokens=max_tokens,
|
|
253
|
+
messages=[
|
|
254
|
+
{
|
|
255
|
+
"role": "user",
|
|
256
|
+
"content": [
|
|
257
|
+
{
|
|
258
|
+
"type": "image",
|
|
259
|
+
"source": {
|
|
260
|
+
"type": "base64",
|
|
261
|
+
"media_type": "image/png",
|
|
262
|
+
"data": image_b64,
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{"type": "text", "text": prompt},
|
|
266
|
+
],
|
|
267
|
+
}
|
|
268
|
+
],
|
|
269
|
+
)
|
|
270
|
+
text = response.content[0].text.strip()
|
|
271
|
+
return extract_json_from_response(text)
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
self._logger.log("agent", f"Vision query failed: {exc}")
|
|
274
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Domain layer -- pure business logic with no external dependencies."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Pure chat message formatting functions.
|
|
2
|
+
|
|
3
|
+
All functions in this module are pure: no I/O, no network, no logging,
|
|
4
|
+
no global state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_chat_note_block(messages: list[dict], meeting_start: str) -> str:
|
|
14
|
+
"""Build a VTT NOTE Chat block from chat messages.
|
|
15
|
+
|
|
16
|
+
Converts absolute timestamps to meeting-start-relative offsets in
|
|
17
|
+
``HH:MM:SS.mmm`` format. The output is a ``NOTE Chat`` block
|
|
18
|
+
containing a JSON array.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
messages: Chat messages with absolute ``"timestamp"`` values.
|
|
22
|
+
meeting_start: Meeting start time as ISO 8601 (e.g.
|
|
23
|
+
``"2026-04-22T11:15+01:00"``).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The ``NOTE Chat`` block string (without surrounding blank lines).
|
|
27
|
+
Returns empty string when *messages* is empty.
|
|
28
|
+
"""
|
|
29
|
+
if not messages:
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
start_dt = datetime.fromisoformat(meeting_start)
|
|
34
|
+
except (ValueError, TypeError):
|
|
35
|
+
start_dt = None
|
|
36
|
+
|
|
37
|
+
offset_messages = []
|
|
38
|
+
for msg in messages:
|
|
39
|
+
entry = dict(msg)
|
|
40
|
+
if start_dt is not None:
|
|
41
|
+
try:
|
|
42
|
+
msg_dt = datetime.fromisoformat(entry["timestamp"])
|
|
43
|
+
delta = msg_dt - start_dt
|
|
44
|
+
total_ms = int(delta.total_seconds() * 1000)
|
|
45
|
+
if total_ms < 0:
|
|
46
|
+
total_ms = 0
|
|
47
|
+
hours = total_ms // 3_600_000
|
|
48
|
+
minutes = (total_ms % 3_600_000) // 60_000
|
|
49
|
+
seconds = (total_ms % 60_000) // 1000
|
|
50
|
+
millis = total_ms % 1000
|
|
51
|
+
entry["offset"] = f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
|
|
52
|
+
except (ValueError, TypeError):
|
|
53
|
+
entry["offset"] = entry["timestamp"]
|
|
54
|
+
else:
|
|
55
|
+
entry["offset"] = entry["timestamp"]
|
|
56
|
+
del entry["timestamp"]
|
|
57
|
+
offset_messages.append(entry)
|
|
58
|
+
|
|
59
|
+
chat_json = json.dumps(offset_messages, indent=2, ensure_ascii=False)
|
|
60
|
+
return f"NOTE Chat\n{chat_json}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def compute_chat_offset(message_timestamp: str, meeting_start: str) -> str:
|
|
64
|
+
"""Compute a single message's offset from meeting start.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
message_timestamp: Message time as ISO 8601.
|
|
68
|
+
meeting_start: Meeting start time as ISO 8601.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Offset as ``HH:MM:SS.mmm``, or the original timestamp on failure.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
start_dt = datetime.fromisoformat(meeting_start)
|
|
75
|
+
msg_dt = datetime.fromisoformat(message_timestamp)
|
|
76
|
+
delta = msg_dt - start_dt
|
|
77
|
+
total_ms = int(delta.total_seconds() * 1000)
|
|
78
|
+
if total_ms < 0:
|
|
79
|
+
total_ms = 0
|
|
80
|
+
hours = total_ms // 3_600_000
|
|
81
|
+
minutes = (total_ms % 3_600_000) // 60_000
|
|
82
|
+
seconds = (total_ms % 60_000) // 1000
|
|
83
|
+
millis = total_ms % 1000
|
|
84
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
|
|
85
|
+
except (ValueError, TypeError):
|
|
86
|
+
return message_timestamp
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def insert_chat_block(content: str, chat_block: str) -> str:
|
|
90
|
+
"""Insert a NOTE Chat block into VTT content at the correct position.
|
|
91
|
+
|
|
92
|
+
The chat block is placed after the existing NOTE Meeting Metadata
|
|
93
|
+
block (if present), or after the WEBVTT header.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
content: Raw VTT file content.
|
|
97
|
+
chat_block: The ``NOTE Chat`` block string.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
VTT content with the chat block inserted.
|
|
101
|
+
"""
|
|
102
|
+
if not chat_block:
|
|
103
|
+
return content
|
|
104
|
+
|
|
105
|
+
# Insert after the existing NOTE block (metadata) if present.
|
|
106
|
+
note_end = content.find("\n\nNOTE")
|
|
107
|
+
if note_end != -1:
|
|
108
|
+
after_note = content.find("\n\n", note_end + 2)
|
|
109
|
+
if after_note != -1:
|
|
110
|
+
insert_pos = after_note + 2
|
|
111
|
+
return content[:insert_pos] + chat_block + "\n\n" + content[insert_pos:]
|
|
112
|
+
return content + "\n\n" + chat_block + "\n"
|
|
113
|
+
if content.startswith("WEBVTT"):
|
|
114
|
+
return content.replace("WEBVTT\n\n", f"WEBVTT\n\n{chat_block}\n\n", 1)
|
|
115
|
+
return f"WEBVTT\n\n{chat_block}\n\n{content}"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Pure HTML-to-text conversion for Teams chat messages.
|
|
2
|
+
|
|
3
|
+
All functions in this module are pure: no I/O, no network, no logging,
|
|
4
|
+
no global state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html as html_mod
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Reply quote removal
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
_REPLY_OPEN_RE = re.compile(
|
|
17
|
+
r'<(\w+)\b[^>]*?itemtype\s*=\s*"http://schema\.skype\.com/Reply"[^>]*>',
|
|
18
|
+
re.IGNORECASE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _remove_reply_quotes(html: str) -> str:
|
|
23
|
+
"""Remove Teams inline reply/quote blocks from *html*.
|
|
24
|
+
|
|
25
|
+
Teams wraps quoted content inside a container element (typically
|
|
26
|
+
``<div>`` or ``<blockquote>``) carrying
|
|
27
|
+
``itemtype="http://schema.skype.com/Reply"``. This function strips the
|
|
28
|
+
entire container and its contents so that only the user's own reply
|
|
29
|
+
text remains.
|
|
30
|
+
|
|
31
|
+
The removal is nesting-aware: if the container tag name (e.g. ``div``)
|
|
32
|
+
appears nested inside the quote, inner open/close pairs are tracked and
|
|
33
|
+
the correct outer closing tag is matched.
|
|
34
|
+
"""
|
|
35
|
+
while True:
|
|
36
|
+
match = _REPLY_OPEN_RE.search(html)
|
|
37
|
+
if not match:
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
tag_name = match.group(1)
|
|
41
|
+
start = match.start()
|
|
42
|
+
pos = match.end()
|
|
43
|
+
|
|
44
|
+
open_re = re.compile(rf"<{tag_name}\b", re.IGNORECASE)
|
|
45
|
+
close_re = re.compile(rf"</{tag_name}\s*>", re.IGNORECASE)
|
|
46
|
+
|
|
47
|
+
depth = 1
|
|
48
|
+
end = len(html) # fallback: remove to end of string
|
|
49
|
+
while depth > 0 and pos < len(html):
|
|
50
|
+
next_open = open_re.search(html, pos)
|
|
51
|
+
next_close = close_re.search(html, pos)
|
|
52
|
+
|
|
53
|
+
if next_close is None:
|
|
54
|
+
# No matching close tag — remove rest of string.
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
if next_open and next_open.start() < next_close.start():
|
|
58
|
+
depth += 1
|
|
59
|
+
pos = next_open.end()
|
|
60
|
+
else:
|
|
61
|
+
depth -= 1
|
|
62
|
+
if depth == 0:
|
|
63
|
+
end = next_close.end()
|
|
64
|
+
pos = next_close.end()
|
|
65
|
+
|
|
66
|
+
html = html[:start] + html[end:]
|
|
67
|
+
|
|
68
|
+
return html
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Public API
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def strip_html_to_text(html: str) -> str:
|
|
77
|
+
"""Convert HTML message content to plain text.
|
|
78
|
+
|
|
79
|
+
Handles common Teams message markup: ``<p>``, ``<br>``, ``<div>``,
|
|
80
|
+
``<a href="...">``, ``<span>``, and HTML entities. Reply quote blocks
|
|
81
|
+
(``itemtype="http://schema.skype.com/Reply"``) are removed entirely so
|
|
82
|
+
that only the author's own text is returned.
|
|
83
|
+
|
|
84
|
+
Returns empty string for blank or whitespace-only content.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
html: Raw HTML string from a Teams chat message.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Plain text with reply quotes removed, block elements converted to
|
|
91
|
+
newlines, links shown as ``text (URL)``, entities decoded, and
|
|
92
|
+
consecutive newlines collapsed to a single newline.
|
|
93
|
+
"""
|
|
94
|
+
if not html:
|
|
95
|
+
return ""
|
|
96
|
+
|
|
97
|
+
text = html
|
|
98
|
+
|
|
99
|
+
# Strip reply/quote blocks before any tag processing so that quoted
|
|
100
|
+
# author names and message text are not included in the output.
|
|
101
|
+
text = _remove_reply_quotes(text)
|
|
102
|
+
|
|
103
|
+
# Convert block-level elements to newlines
|
|
104
|
+
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
|
105
|
+
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
|
|
106
|
+
text = re.sub(r"</div>", "\n", text, flags=re.IGNORECASE)
|
|
107
|
+
text = re.sub(r"</li>", "\n", text, flags=re.IGNORECASE)
|
|
108
|
+
|
|
109
|
+
# Extract link text with URL: <a href="URL">text</a> -> text (URL)
|
|
110
|
+
text = re.sub(
|
|
111
|
+
r'<a\s[^>]*href="([^"]*)"[^>]*>([^<]*)</a>',
|
|
112
|
+
r"\2 (\1)",
|
|
113
|
+
text,
|
|
114
|
+
flags=re.IGNORECASE,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Remove all remaining HTML tags
|
|
118
|
+
text = re.sub(r"<[^>]+>", "", text)
|
|
119
|
+
|
|
120
|
+
# Decode HTML entities
|
|
121
|
+
text = html_mod.unescape(text)
|
|
122
|
+
|
|
123
|
+
# Collapse consecutive newlines into a single newline.
|
|
124
|
+
text = re.sub(r"\n{2,}", "\n", text)
|
|
125
|
+
|
|
126
|
+
return text.strip()
|