Zoom Docs Summary/Transcript Exporter

Adds three export buttons to Zoom Docs pages (Transcript only, Summary only, or Both) that save an Obsidian-ready Markdown file with meeting title, date, time and attendees in the frontmatter

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Zoom Docs Summary/Transcript Exporter
// @namespace    zoom-transcript-exporter
// @version      4.1
// @description  Adds three export buttons to Zoom Docs pages (Transcript only, Summary only, or Both) that save an Obsidian-ready Markdown file with meeting title, date, time and attendees in the frontmatter
// @match        https://hub.zoom.us/doc/*
// @match        https://docs.zoom.us/doc/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // Zoom Docs titles look like "Paul / Avery Connect 2026-07-23 09:00(GMT+10:00)".
  // Strip a leading emoji (Zoom prefixes some doc titles with one) and split off
  // the trailing date/time/timezone that Zoom appends automatically.
  const TITLE_RE = /^(.*?)\s+(\d{4}-\d{2}-\d{2})\s+(\d{1,2}:\d{2})\(GMT([+-]\d{1,2}(?::\d{2})?)\)\s*$/;

  function extractMeetingMeta() {
    const raw = (document.querySelector('.zm-page-title-content')?.textContent || document.title || '')
      .replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '')
      .trim();

    const match = raw.match(TITLE_RE);
    if (match) {
      const [, title, date, time, tz] = match;
      return { title: title.trim(), date, time, timezone: `GMT${tz}` };
    }
    // Fall back to the raw title with no parsed date/time rather than guessing.
    return { title: raw, date: null, time: null, timezone: null };
  }

  function extractAttendees() {
    const seen = new Set();
    document.querySelectorAll('.zm-doc-avatar[aria-label]').forEach((el) => {
      seen.add(el.getAttribute('aria-label').trim());
    });
    return Array.from(seen);
  }

  function extractSummaryBlocks() {
    const container = document.querySelector('.editor-block-children-container');
    if (!container) return [];
    const blocks = [];
    Array.from(container.children).forEach((el) => {
      const type = el.getAttribute('data-block-type');
      // Citation markers (footnote-style numbers linking back to the transcript)
      // render as bare digits wrapped in zero-width characters — strip them
      // before reading text so they don't show up as stray numbers.
      const clone = el.cloneNode(true);
      clone.querySelectorAll('.zm-citation').forEach((c) => c.remove());
      const text = clone.textContent.replace(/[\u200B\uFEFF]/g, '').trim();
      if (!text) return;

      if (type === 'BLOCK_TYPE_HEADING2') {
        blocks.push({ type: 'heading', text });
      } else if (type === 'BLOCK_TYPE_BULLET') {
        blocks.push({ type: 'bullet', text: text.replace(/^•\s*/, '') });
      } else {
        blocks.push({ type: 'paragraph', text });
      }
    });
    return blocks;
  }

  function buildSummaryMarkdown(blocks) {
    const lines = [];
    blocks.forEach((b, i) => {
      const prev = blocks[i - 1];
      if (i > 0 && !(b.type === 'bullet' && prev.type === 'bullet')) lines.push('');
      if (b.type === 'heading') lines.push(`## ${b.text}`);
      else if (b.type === 'bullet') lines.push(`- ${b.text}`);
      else lines.push(b.text);
    });
    return lines.join('\n');
  }

  function wrapInCallout(title, body, { collapsed = true } = {}) {
    const marker = collapsed ? '-' : '+';
    const quoted = body.split('\n').map((line) => (line ? `> ${line}` : '>'));
    return [`> [!note]${marker} ${title}`, ...quoted].join('\n');
  }

  function extractTranscript() {
    const timeRe = /^\d{1,2}:\d{2}:\d{2}$/;
    const spans = document.querySelectorAll('span');
    const messages = [];

    spans.forEach((span) => {
      const txt = span.textContent.trim();
      if (!timeRe.test(txt)) return;

      const header = span.parentElement;
      if (!header) return;
      const messageDiv = header.parentElement;
      if (!messageDiv) return;

      let speaker = '';
      for (const child of header.children) {
        if (child === span) continue;
        const t = child.textContent.trim();
        if (t) speaker = t;
      }

      let text = '';
      for (const child of messageDiv.children) {
        if (child === header) continue;
        const t = child.textContent.trim();
        if (t) text += (text ? '\n' : '') + t;
      }

      if (speaker || text) {
        messages.push({ speaker, time: txt, text });
      }
    });

    return messages;
  }

  function yamlString(value) {
    return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
  }

  function buildFrontmatter(meta, attendees) {
    const lines = ['---', `title: ${yamlString(meta.title)}`];
    if (meta.date) lines.push(`date: ${meta.date}`);
    if (meta.time) lines.push(`time: ${yamlString(meta.time)}`);
    if (meta.timezone) lines.push(`timezone: ${yamlString(meta.timezone)}`);
    lines.push('attendees:');
    attendees.forEach((name) => lines.push(`  - ${yamlString(name)}`));
    lines.push(`source: ${yamlString(location.href)}`);
    lines.push('tags:', '  - zoom-transcript');
    lines.push('---', '');
    return lines.join('\n');
  }

  function buildBody(messages) {
    return messages
      .map((m) => `**${m.speaker}** · ${m.time}\n${m.text}`)
      .join('\n\n');
  }

  function buildFilename(meta, suffix) {
    const namePart = meta.title || document.title || 'transcript';
    const datePart = meta.date ? ` ${meta.date}` : '';
    const suffixPart = suffix ? ` (${suffix})` : '';
    return `${namePart}${datePart}${suffixPart}`.replace(/[\/:]/g, '_') + '.md';
  }

  function downloadMarkdown(content, filename) {
    const blob = new Blob([content], { type: 'text/markdown' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
  }

  function exportTranscriptOnly() {
    const messages = extractTranscript();
    if (!messages.length) {
      alert('No transcript found on this page. Make sure the Transcript panel is open before exporting.');
      return;
    }

    const meta = extractMeetingMeta();
    const attendees = extractAttendees();
    const content = [buildFrontmatter(meta, attendees).trim(), buildBody(messages)].join('\n\n') + '\n';
    downloadMarkdown(content, buildFilename(meta, 'Transcript'));
  }

  function exportSummaryOnly() {
    const summaryBlocks = extractSummaryBlocks();
    if (!summaryBlocks.length) {
      alert('No AI summary found on this page.');
      return;
    }

    const meta = extractMeetingMeta();
    const attendees = extractAttendees();
    const content = [buildFrontmatter(meta, attendees).trim(), buildSummaryMarkdown(summaryBlocks)].join('\n\n') + '\n';
    downloadMarkdown(content, buildFilename(meta, 'Summary'));
  }

  function exportBoth() {
    const messages = extractTranscript();
    const summaryBlocks = extractSummaryBlocks();
    if (!messages.length && !summaryBlocks.length) {
      alert('No summary or transcript found on this page.');
      return;
    }

    const meta = extractMeetingMeta();
    const attendees = extractAttendees();

    const sections = [buildFrontmatter(meta, attendees).trim()];
    if (summaryBlocks.length) sections.push(buildSummaryMarkdown(summaryBlocks));
    if (messages.length) sections.push(wrapInCallout('Transcript', buildBody(messages)));

    const content = sections.join('\n\n') + '\n';
    downloadMarkdown(content, buildFilename(meta));
  }

  function addButtons() {
    if (document.getElementById('__transcript_export_container')) return;

    const container = document.createElement('div');
    container.id = '__transcript_export_container';
    Object.assign(container.style, {
      position: 'fixed',
      top: '10px',
      right: '10px',
      zIndex: 999999,
      display: 'flex',
      flexDirection: 'column',
      gap: '6px',
    });

    const buttonStyle = {
      padding: '10px 14px',
      background: '#2D8CFF',
      color: '#fff',
      border: 'none',
      borderRadius: '4px',
      fontSize: '14px',
      cursor: 'pointer',
      textAlign: 'left',
    };

    const buttons = [
      { label: 'Export Transcript (.md)', handler: exportTranscriptOnly },
      { label: 'Export Summary (.md)', handler: exportSummaryOnly },
      { label: 'Export Both (.md)', handler: exportBoth },
    ];

    buttons.forEach(({ label, handler }) => {
      const btn = document.createElement('button');
      btn.textContent = label;
      Object.assign(btn.style, buttonStyle);
      btn.addEventListener('click', handler);
      container.appendChild(btn);
    });

    document.body.appendChild(container);
  }

  window.addEventListener('load', addButtons);
  // in case the page is already loaded when the script runs
  if (document.readyState === 'complete') addButtons();
})();