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 gwsfails → 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 bothelem.get(...)andtr.get(...)or you'll miss most suggestions. - Replace = same suggestion ID in both insertions and deletions: A single suggestion ID appearing in both
suggestedInsertionIdsandsuggestedDeletionIdsacross different elements means it's a replacement. - Multi-tab docs: Use
includeTabsContent: trueor tabs return empty bodies. The tab structure isdoc.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-allreturns one JSON object per line. Parse each line separately.