233 lines
8.3 KiB
Python
233 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Extract a Fireflies.ai (or similar) meeting transcript from a saved .mhtml page.
|
|
|
|
Usage:
|
|
python3 extract_transcript.py <path/to/page.mhtml>
|
|
|
|
Writes <path/to/page>.md next to the input file (same directory, same
|
|
basename, .md extension), containing the meeting title/date if found and
|
|
the speaker-by-speaker transcript with timestamps.
|
|
"""
|
|
import email
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from bs4 import BeautifulSoup
|
|
except ImportError:
|
|
sys.exit(
|
|
"Missing dependency 'beautifulsoup4'. Install it with:\n"
|
|
" pip3 install beautifulsoup4"
|
|
)
|
|
|
|
MONTHS = "Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec"
|
|
DATE_RE = re.compile(rf"({MONTHS})[a-z]* \d{{1,2}},? \d{{4}}(?:, \d{{1,2}}:\d{{2}} ?[AP]M)?")
|
|
|
|
# Fireflies virtualizes the transcript list - if the user didn't scroll
|
|
# through the whole thing before saving the page, only the visible portion
|
|
# ends up in the DOM/mhtml, leaving silent gaps. A gap this long between
|
|
# consecutive timestamps is a strong signal that's what happened.
|
|
GAP_WARNING_SECONDS = 90
|
|
|
|
|
|
def parse_timestamp(ts):
|
|
if not ts:
|
|
return None
|
|
parts = ts.split(":")
|
|
try:
|
|
parts = [int(p) for p in parts]
|
|
except ValueError:
|
|
return None
|
|
seconds = 0
|
|
for p in parts:
|
|
seconds = seconds * 60 + p
|
|
return seconds
|
|
|
|
|
|
def find_large_gaps(lines):
|
|
gaps = []
|
|
prev_ts = None
|
|
prev_idx = None
|
|
for idx, (ts, _, _) in enumerate(lines):
|
|
secs = parse_timestamp(ts)
|
|
if secs is None:
|
|
continue
|
|
if prev_ts is not None and secs - prev_ts >= GAP_WARNING_SECONDS:
|
|
gaps.append((prev_idx, idx, secs - prev_ts))
|
|
prev_ts, prev_idx = secs, idx
|
|
return gaps
|
|
|
|
|
|
def load_html_parts(mhtml_path):
|
|
with open(mhtml_path, "rb") as f:
|
|
msg = email.message_from_binary_file(f)
|
|
for part in msg.walk():
|
|
if part.get_content_type() != "text/html":
|
|
continue
|
|
payload = part.get_payload(decode=True)
|
|
if not payload:
|
|
continue
|
|
charset = part.get_content_charset() or "utf-8"
|
|
yield payload.decode(charset, errors="replace")
|
|
|
|
|
|
def find_transcript_container(html):
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
container = soup.find(id=lambda i: i and i.endswith("content-transcript"))
|
|
if container is not None:
|
|
return soup, container
|
|
# Fallback: some captures use the ScrollArea class directly without the
|
|
# radix id being present in this particular MIME part.
|
|
candidates = soup.find_all(
|
|
class_=lambda c: c and any("ScrollArea-styled__Root" in x for x in (c if isinstance(c, list) else [c]))
|
|
)
|
|
if candidates:
|
|
# The transcript panel is reliably the largest ScrollArea on the page.
|
|
best = max(candidates, key=lambda el: len(el.get_text()))
|
|
if len(best.get_text(strip=True)) > 0:
|
|
return soup, best
|
|
return soup, None
|
|
|
|
|
|
def extract_paragraphs(container):
|
|
paragraphs = container.find_all(
|
|
"div", id=lambda i: i and i.startswith("transcript-paragraph-")
|
|
)
|
|
lines = []
|
|
last_name = None
|
|
for p in paragraphs:
|
|
name_span = p.find("span", class_="name")
|
|
name = name_span.get_text(strip=True) if name_span else (last_name or "Unknown speaker")
|
|
last_name = name
|
|
ts_span = p.find("span", attrs={"text-decoration": "underline"})
|
|
ts = ts_span.get_text(strip=True) if ts_span else ""
|
|
content_div = p.find("div", class_=lambda c: c and "ContentPost-styled__Content" in c)
|
|
text = content_div.get_text(" ", strip=True) if content_div else p.get_text(" ", strip=True)
|
|
if not text:
|
|
continue
|
|
lines.append((ts, name, text))
|
|
return lines
|
|
|
|
|
|
def find_inactive_transcript_tab(html_parts):
|
|
"""Detect the common failure case: the page was saved with a different
|
|
tab (usually Notes) active, so Fireflies never mounted the transcript
|
|
panel into the DOM at all - there's nothing to extract, not a selector
|
|
mismatch."""
|
|
for html in html_parts:
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
tab = soup.find(
|
|
attrs={"role": "tab"},
|
|
id=lambda i: i and i.endswith("trigger-transcript"),
|
|
)
|
|
if tab is not None:
|
|
active_tab = soup.find(attrs={"role": "tab", "data-state": "active"})
|
|
active_name = active_tab.get_text(strip=True) if active_tab else "another tab"
|
|
return tab.get("data-state") != "active", active_name
|
|
return False, None
|
|
|
|
|
|
def extract_title_and_date(html_parts):
|
|
title = None
|
|
date = None
|
|
for html in html_parts:
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
if title is None and soup.title and soup.title.get_text(strip=True):
|
|
title = soup.title.get_text(strip=True)
|
|
if date is None:
|
|
m = DATE_RE.search(soup.get_text(" ", strip=True))
|
|
if m:
|
|
date = m.group(0)
|
|
if title and date:
|
|
break
|
|
return title, date
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 2:
|
|
sys.exit(f"Usage: {sys.argv[0]} <path/to/page.mhtml>")
|
|
|
|
src = Path(sys.argv[1]).expanduser()
|
|
if not src.is_file():
|
|
sys.exit(f"File not found: {src}")
|
|
|
|
html_parts = list(load_html_parts(src))
|
|
if not html_parts:
|
|
sys.exit("No text/html part found in this .mhtml file - is it a valid MIME HTML capture?")
|
|
|
|
lines = []
|
|
for html in html_parts:
|
|
_, container = find_transcript_container(html)
|
|
if container is None:
|
|
continue
|
|
lines = extract_paragraphs(container)
|
|
if lines:
|
|
break
|
|
|
|
if not lines:
|
|
tab_inactive, active_name = find_inactive_transcript_tab(html_parts)
|
|
if tab_inactive:
|
|
sys.exit(
|
|
f"No transcript found - this page was saved with the "
|
|
f"'{active_name}' tab open, not 'Transcript'. Fireflies only "
|
|
f"renders the active tab's content into the page, so the "
|
|
f"transcript panel isn't in this file at all. Reopen the "
|
|
f"meeting, click the 'Transcript' tab, wait for it to load "
|
|
f"and scroll to the end (so the full transcript renders), "
|
|
f"then re-save the page and try again."
|
|
)
|
|
sys.exit(
|
|
"Could not find a transcript panel in this file. This script targets "
|
|
"Fireflies.ai-style pages (a ScrollArea containing "
|
|
"'#*-content-transcript'). If the page structure differs, the "
|
|
"selectors in extract_transcript.py need updating."
|
|
)
|
|
|
|
title, date = extract_title_and_date(html_parts)
|
|
speakers = sorted(set(name for _, name, _ in lines))
|
|
|
|
out_path = src.with_suffix(".md")
|
|
|
|
body = []
|
|
body.append(f"# {title or src.stem}")
|
|
body.append("")
|
|
if date:
|
|
body.append(f"- **Date:** {date}")
|
|
body.append(f"- **Attendees (speakers detected):** {', '.join(speakers)}")
|
|
body.append(f"- **Source:** `{src.name}`")
|
|
body.append("")
|
|
body.append("## Transcript")
|
|
body.append("")
|
|
# Blank line between entries (not a single "\n") so each timestamp
|
|
# starts its own paragraph in Markdown preview - a lone newline is a
|
|
# soft break that most renderers collapse into one running paragraph.
|
|
entries = []
|
|
for ts, name, text in lines:
|
|
prefix = f"[{ts}] " if ts else ""
|
|
entries.append(f"{prefix}{name}: {text}")
|
|
body.append("\n\n".join(entries))
|
|
|
|
out_path.write_text("\n".join(body) + "\n", encoding="utf-8")
|
|
print(f"Wrote {len(lines)} transcript lines to {out_path}")
|
|
|
|
gaps = find_large_gaps(lines)
|
|
if gaps:
|
|
print(
|
|
f"WARNING: {len(gaps)} gap(s) of {GAP_WARNING_SECONDS}s or more "
|
|
f"between consecutive lines - Fireflies virtualizes the transcript "
|
|
f"list, so this usually means the page was saved before scrolling "
|
|
f"through the whole transcript, and content in between is simply "
|
|
f"missing (not silence). Re-open the meeting, scroll the "
|
|
f"Transcript tab all the way to the end first, then re-save and "
|
|
f"re-run this script:",
|
|
file=sys.stderr,
|
|
)
|
|
for start_idx, end_idx, gap_secs in gaps:
|
|
start_ts = lines[start_idx][0]
|
|
end_ts = lines[end_idx][0]
|
|
print(f" - {start_ts} -> {end_ts} ({gap_secs}s gap)", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|