| 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 |
|
Extract every comment, suggestion (add/delete/replace/style), and reply from a Google Doc into a structured CSV.
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
Google Docs URLs follow this pattern:
https://docs.google.com/document/d/{DOCUMENT_ID}/edit...
Extract the ID between /d/ and the next /.
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/nullThis returns NDJSON (one JSON object per page). Parse all pages and collect every comment.
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.jsonThen 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())))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.
After saving, print a summary:
- Total items (comments + suggestions)
- Breakdown by type (Comment, Add, Delete, Replace, Style)
- Which tabs had content
- File path
- 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.