Skip to content

Latest commit

 

History

History
185 lines (144 loc) · 7.66 KB

File metadata and controls

185 lines (144 loc) · 7.66 KB
name gdoc-comment-extractor
description Extract all comments, suggestions, and tracked changes from a Google Doc and save them as a CSV. Use when the user shares a Google Docs URL and wants to pull out comments, edits, suggestions, or tracked changes. Also triggers on "extract comments", "pull comments from doc", "get suggestions from google doc", "export doc comments", "doc feedback", or "what did the editor say".
allowed-tools Bash(gws:*), Bash(python3:*), Bash(which:*), Write, Read
metadata
bashPattern filePattern priority
80

Google Doc Comment & Suggestion Extractor

Extract every comment, suggestion (add/delete/replace/style), and reply from a Google Doc into a structured CSV.

Prerequisites

This skill requires the Google Workspace CLI (gws). If gws is not installed or not authenticated, refer to GWS_SETUP.md in this skill's directory and walk the user through setup before proceeding.

Quick check — run this before anything else:

which gws && gws docs documents get --params '{"documentId": "1"}' 2>&1 | head -3
  • If which gws fails → follow GWS_SETUP.md install steps
  • If you get a 401/403 → follow GWS_SETUP.md auth steps
  • If you get a "not found" error for the document → CLI is working, proceed

Workflow

1. Parse the document ID from the URL

Google Docs URLs follow this pattern:

https://docs.google.com/document/d/{DOCUMENT_ID}/edit...

Extract the ID between /d/ and the next /.

2. Fetch comments (Drive API)

Comments are threaded discussions anchored to highlighted text. They come from the Drive API, not the Docs API.

gws drive comments list --params '{"fileId": "<DOC_ID>", "fields": "comments(author/displayName,content,quotedFileContent/value,replies(author/displayName,content),createdTime,resolved),nextPageToken", "pageSize": 100, "includeDeleted": true}' --page-all 2>/dev/null

This returns NDJSON (one JSON object per page). Parse all pages and collect every comment.

3. Fetch suggestions (Docs API)

Suggestions (tracked changes: add, delete, replace, style) are embedded in the document body — they are NOT returned by the Drive comments API.

Use includeTabsContent: true to get content from all tabs in multi-tab documents.

gws docs documents get --params '{"documentId": "<DOC_ID>", "includeTabsContent": true}' 2>/dev/null > /tmp/gdoc_extract.json

Then extract suggestions with a Python script. Save the script to a temp file and run it:

import json, html
from collections import Counter

with open('/tmp/gdoc_extract.json') as f:
    doc = json.load(f)

suggestions = {}

def process_content(content, tab_title):
    if not content:
        return
    for item in content.get('content', []):
        process_structural(item, tab_title)

def process_structural(item, tab_title):
    if 'paragraph' in item:
        for elem in item['paragraph'].get('elements', []):
            process_element(elem, tab_title)
    if 'table' in item:
        for row in item['table'].get('tableRows', []):
            for cell in row.get('tableCells', []):
                for ci in cell.get('content', []):
                    process_structural(ci, tab_title)

def process_element(elem, tab_title):
    tr = elem.get('textRun', {})
    text = html.unescape(tr.get('content', ''))

    # IMPORTANT: suggestedInsertionIds and suggestedDeletionIds can appear
    # at BOTH the element level AND inside the textRun. Check both.
    all_ins = set(elem.get('suggestedInsertionIds', []) + tr.get('suggestedInsertionIds', []))
    all_del = set(elem.get('suggestedDeletionIds', []) + tr.get('suggestedDeletionIds', []))

    for sid in all_ins:
        suggestions.setdefault(sid, {'insertions': [], 'deletions': [], 'style_changes': [], 'tab': tab_title})
        suggestions[sid]['insertions'].append(text)
    for sid in all_del:
        suggestions.setdefault(sid, {'insertions': [], 'deletions': [], 'style_changes': [], 'tab': tab_title})
        suggestions[sid]['deletions'].append(text)

    # suggestedTextStyleChanges can also appear at both levels
    all_style = set(
        list(tr.get('suggestedTextStyleChanges', {}).keys()) +
        list(elem.get('suggestedTextStyleChanges', {}).keys())
    )
    for sid in all_style:
        suggestions.setdefault(sid, {'insertions': [], 'deletions': [], 'style_changes': [], 'tab': tab_title})
        suggestions[sid]['style_changes'].append(text)

    # Handle inline objects (images, etc.)
    if 'inlineObjectElement' in elem:
        ioe = elem.get('inlineObjectElement', {})
        for sid in set(elem.get('suggestedInsertionIds', []) + ioe.get('suggestedInsertionIds', [])):
            suggestions.setdefault(sid, {'insertions': [], 'deletions': [], 'style_changes': [], 'tab': tab_title})
            suggestions[sid]['insertions'].append('[image/object]')
        for sid in set(elem.get('suggestedDeletionIds', []) + ioe.get('suggestedDeletionIds', [])):
            suggestions.setdefault(sid, {'insertions': [], 'deletions': [], 'style_changes': [], 'tab': tab_title})
            suggestions[sid]['deletions'].append('[image/object]')

# Process all tabs
for tab in doc.get('tabs', []):
    title = tab.get('tabProperties', {}).get('title', 'Untitled')
    process_content(tab.get('documentTab', {}).get('body', {}), title)

# Classify each suggestion
for sid, s in suggestions.items():
    ins = ''.join(s['insertions']).strip()
    dele = ''.join(s['deletions']).strip()
    if ins and dele:
        s['type'] = 'Replace'
    elif ins:
        s['type'] = 'Add'
    elif dele:
        s['type'] = 'Delete'
    elif s['style_changes']:
        s['type'] = 'Style'
    else:
        s['type'] = 'Unknown'

# Output as JSON for the CSV generation step
print(json.dumps(list(suggestions.values())))

4. Combine into CSV

Merge comments and suggestions into a single CSV with these columns:

Column Description
type Comment, Add, Delete, Replace, or Style
tab Which document tab (empty for comments — they span all tabs)
author Who left the comment (empty for suggestions — Docs API doesn't expose this)
highlighted_text The text the comment was anchored to
comment The comment text
original_text For suggestions: the deleted/replaced text
new_text For suggestions: the inserted text
resolved Whether the comment is resolved
created ISO timestamp (comments only)
replies Semicolon-separated replies (comments only)

Save the CSV as {Document Title} - comments.csv in the current working directory.

5. Report results

After saving, print a summary:

  • Total items (comments + suggestions)
  • Breakdown by type (Comment, Add, Delete, Replace, Style)
  • Which tabs had content
  • File path

Key gotchas

  • Two APIs required: Comments come from Drive API. Suggestions come from Docs API. Neither has the full picture alone.
  • suggestedInsertionIds location: These live INSIDE textRun, not at the element level. You must check both elem.get(...) and tr.get(...) or you'll miss most suggestions.
  • Replace = same suggestion ID in both insertions and deletions: A single suggestion ID appearing in both suggestedInsertionIds and suggestedDeletionIds across different elements means it's a replacement.
  • Multi-tab docs: Use includeTabsContent: true or tabs return empty bodies. The tab structure is doc.tabs[].documentTab.body.
  • Comments + suggestions = UI count: The number shown in Google Docs' sidebar is the sum of both. If the user says "there are N comments" and you only find a fraction, you're probably missing suggestions.
  • NDJSON pagination: --page-all returns one JSON object per line. Parse each line separately.