# Gdoc Comment Extractor

> 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".

- Skill: `everyinc/gdoc-comment-extractor` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add everyinc/gdoc-comment-extractor`
- Raw SKILL.md: https://api.skillmd.com/api/skills/everyinc/gdoc-comment-extractor/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: everyinc (https://skillmd.com/u/everyinc)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/everyinc/gdoc-comment-extractor

---


# 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:

```bash
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.

```bash
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.

```bash
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:

```python
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.

