
Gs Export
- 175 installs
- 470 repo stars
- Updated March 13, 2026
- cookjohn/gs-skills
Export Google Scholar citation metadata into bibliographic formats for reference managers, manuscripts, and structured literature inventories.
About
Google Scholar export skill that harvests citation metadata from search and profile results and formats bibliographic entries for reference managers and manuscripts, letting Claude agents build structured literature inventories without manual Scholar copy-paste during research.
- Exports Google Scholar citation metadata
- Produces bibliographic records for reference tools
- Consolidates search results into reusable citation lists
- Reduces manual copy-paste from Scholar result pages
Gs Export by the numbers
- 175 all-time installs (skills.sh)
- Ranked #3,092 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cookjohn/gs-skills --skill gs-exportAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 175 |
|---|---|
| repo stars | ★ 470 |
| Last updated | March 13, 2026 |
| Repository | cookjohn/gs-skills ↗ |
What it does
Export Google Scholar citation metadata into bibliographic formats for reference managers, manuscripts, and structured literature inventories.
Files
Google Scholar Export to Zotero
Export Google Scholar paper citation data via BibTeX extraction and push to Zotero desktop.
Arguments
$ARGUMENTS contains one or more data-cids (space-separated), e.g.:
TFS2GgoGiNUJ— single paperTFS2GgoGiNUJ abc123XYZ def456UVW— batch export
Steps
Step 1: Get BibTeX for each paper
For each data-cid, perform 3 tool calls to bypass CORS:
1a. Fetch cite dialog to get BibTeX link (evaluate_script)
async () => {
const cid = "DATA_CID_HERE";
const resp = await fetch(
`https://scholar.google.com/scholar?q=info:${cid}:scholar.google.com/&output=cite`,
{ credentials: 'include' }
);
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
// Extract export links
const links = Array.from(doc.querySelectorAll('#gs_citi a')).map(a => ({
format: a.textContent.trim(),
url: a.href
}));
// Extract citation format texts
const citations = Array.from(doc.querySelectorAll('#gs_citt tr')).map(tr => {
const cells = tr.querySelectorAll('td');
return {
style: cells[0]?.textContent?.trim() || '',
text: cells[1]?.textContent?.trim() || ''
};
});
const bibtexLink = links.find(l => l.format === 'BibTeX');
return { cid, bibtexLink: bibtexLink?.url || '', links, citations };
}1b. Navigate to BibTeX URL (navigate_page)
Use mcp__chrome-devtools__navigate_page:
- url: the
bibtexLinkURL from step 1a (onscholar.googleusercontent.com)
This bypasses CORS restrictions that block fetch() to googleusercontent.com.
1c. Read BibTeX content (evaluate_script)
async () => {
return { bibtex: document.body.innerText || document.body.textContent || '' };
}Step 2: Parse BibTeX and push to Zotero
Save the BibTeX data as JSON, then call the push script:
python "E:/gscholar-skills/.claude/skills/gs-export/scripts/push_to_zotero.py" /tmp/gs_papers.jsonBefore calling the script, construct a JSON file at /tmp/gs_papers.json containing paper data parsed from BibTeX. Parse the BibTeX yourself and create the JSON array:
[
{
"pmid": "",
"title": "The title from BibTeX",
"authors": [
{"lastName": "Smith", "firstName": "John"}
],
"journal": "Journal Name",
"journalAbbr": "",
"pubdate": "2022",
"volume": "14",
"issue": "4",
"pages": "1054",
"doi": "",
"pdfUrl": "https://example.com/paper.pdf",
"abstract": "",
"keywords": [],
"language": "en",
"pubtype": ["Journal Article"]
}
]IMPORTANT: Set pdfUrl from the search result's fullTextUrl field (the PDF link extracted by gs-search). The Python script will download the PDF and upload it to Zotero via /connector/saveAttachment (Zotero 7.x ignores attachments in saveItems). PDF download may fail for some publishers (403, JS-redirect); these are reported as "PDF skip".
BibTeX fields mapping:
@article{key,→itemType: journalArticle@inproceedings{key,→itemType: conferencePaper@book{key,→itemType: booktitle={...}→titleauthor={Last1, First1 and Last2, First2}→authorsarrayjournal={...}→journalyear={...}→pubdatevolume={...}→volumenumber={...}→issuepages={...}→pagespublisher={...}→ (included in extra or publisher field)
Step 3: Report
Single paper:
Exported to Zotero from Google Scholar:
Title: {title}
Authors: {authors}
Journal: {journal} ({year})
Data-CID: {dataCid}Batch:
Exported {count} papers to Zotero from Google Scholar:
1. {title1} ({journal1}, {year1})
2. {title2} ({journal2}, {year2})
...Batch Export Optimization
For multiple papers, process sequentially to avoid CAPTCHA: 1. Get all BibTeX links in one evaluate_script call (fetch all cite dialogs) 2. Navigate to each BibTeX URL one at a time 3. Collect all BibTeX entries 4. Push all to Zotero in a single batch
Notes
- Single paper export uses 3-4 tool calls:
evaluate_script(cite dialog) +navigate_page(BibTeX URL) +evaluate_script(read BibTeX) +bash python(Zotero push) - Batch export: 2N+1 tool calls (N papers: N navigate + N evaluate + 1 bash)
- BibTeX links are on
scholar.googleusercontent.com— CORS blocks fetch(), so we use navigate_page to bypass - Reuses
push_to_zotero.pyfor Zotero Connector API communication - Google Scholar BibTeX does NOT include abstract or DOI — these fields will be empty in Zotero
- After export, navigate back to Google Scholar page:
navigate_pagewith typeback
#!/usr/bin/env python3
"""Push PubMed/Google Scholar paper data to Zotero via local Connector API.
Three-step flow (Zotero 7.x saveItems ignores attachments field):
1. saveItems — save metadata
2. download_pdf — download PDF binary via Python urllib
3. saveAttachment — upload PDF binary to Zotero, linked to parent item
Session strategy: deterministic sessionID derived from content hash.
- 201 = saved successfully
- 409 = SESSION_EXISTS = already saved (idempotent, treat as success)
"""
import json
import sys
import io
import hashlib
import urllib.request
import urllib.error
import re
from datetime import datetime, timezone
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
ZOTERO_API = 'http://127.0.0.1:23119/connector'
HTTP_TIMEOUT = 15
PDF_DOWNLOAD_TIMEOUT = 60
def zotero_request(endpoint, data=None, timeout=HTTP_TIMEOUT):
"""Send request to Zotero local API with timeout."""
url = f'{ZOTERO_API}/{endpoint}'
body = json.dumps(data or {}, ensure_ascii=False).encode('utf-8')
req = urllib.request.Request(url, data=body, headers={
'Content-Type': 'application/json',
'X-Zotero-Connector-API-Version': '3'
})
try:
resp = urllib.request.urlopen(req, timeout=timeout)
text = resp.read().decode('utf-8')
return resp.status, json.loads(text) if text else None
except urllib.error.HTTPError as e:
resp_body = e.read().decode('utf-8', errors='replace')
try:
return e.code, json.loads(resp_body) if resp_body else None
except json.JSONDecodeError:
return e.code, {'error': resp_body}
except urllib.error.URLError:
return 0, None
except TimeoutError:
return -1, {'error': f'Request timeout ({timeout}s)'}
def make_session_id(items):
"""Generate deterministic sessionID from item content (titles hash)."""
key = '|'.join(sorted(item.get('title', '') for item in items))
return hashlib.md5(key.encode('utf-8', errors='surrogateescape')).hexdigest()[:12]
def get_selected_collection():
"""Get currently selected Zotero collection."""
status, data = zotero_request('getSelectedCollection')
if status != 200 or not data:
return None
return data
def list_collections():
"""List all available Zotero collections."""
data = get_selected_collection()
if not data:
print('Error: Cannot connect to Zotero. Please ensure Zotero desktop is running.')
return
print(f'Current collection: {data.get("name", "?")} (ID: {data.get("id", "?")})')
print(f'Library: {data.get("libraryName", "?")}')
print()
print('Available collections:')
for t in data.get('targets', []):
indent = ' ' * t.get('level', 0)
recent = ' *' if t.get('recent') else ''
print(f' {indent}{t["name"]} (ID: {t["id"]}){recent}')
def parse_pubmed_authors(author_str):
"""Parse PubMed author string into Zotero creator list.
PubMed format: "LastName Initials" e.g. "Váša F, Mišić B"
or "LastName ForeName" e.g. "Smith John A"
"""
if not author_str:
return []
authors = []
for name in re.split(r',\s*', author_str):
name = name.strip()
if not name:
continue
parts = name.split(' ', 1)
if len(parts) == 2:
authors.append({
'lastName': parts[0],
'firstName': parts[1],
'creatorType': 'author'
})
else:
authors.append({
'name': name,
'creatorType': 'author'
})
return authors
def build_zotero_item(paper):
"""Build Zotero item JSON from PubMed paper data."""
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
# Parse authors
if isinstance(paper.get('authors'), list) and paper['authors']:
if isinstance(paper['authors'][0], dict):
creators = []
for a in paper['authors']:
if 'lastName' in a:
creators.append({
'lastName': a['lastName'],
'firstName': a.get('firstName', a.get('initials', '')),
'creatorType': 'author'
})
elif 'name' in a:
parts = a['name'].split(' ', 1)
if len(parts) == 2:
creators.append({
'lastName': parts[0],
'firstName': parts[1],
'creatorType': 'author'
})
else:
creators.append({
'name': a['name'],
'creatorType': 'author'
})
else:
creators = parse_pubmed_authors(', '.join(paper['authors']))
elif isinstance(paper.get('authors'), str):
creators = parse_pubmed_authors(paper['authors'])
else:
creators = []
item = {
'itemType': 'journalArticle',
'title': paper.get('title', ''),
'abstractNote': paper.get('abstract', ''),
'date': paper.get('pubDate') or paper.get('pubdate', ''),
'language': paper.get('language', 'en'),
'libraryCatalog': paper.get('libraryCatalog', 'PubMed'),
'accessDate': now,
'volume': paper.get('volume', ''),
'pages': paper.get('pages', ''),
'publicationTitle': paper.get('journal') or paper.get('fulljournalname', ''),
'journalAbbreviation': paper.get('journalAbbr') or paper.get('source', ''),
'issue': paper.get('issue', ''),
'DOI': paper.get('doi', ''),
'url': f'https://pubmed.ncbi.nlm.nih.gov/{paper["pmid"]}/' if paper.get('pmid') else '',
'creators': creators,
'tags': [{'tag': k, 'type': 1} for k in paper.get('keywords', [])],
'attachments': [],
}
# ISSN
if paper.get('issn'):
item['ISSN'] = paper['issn']
# Extra field with PMID and other metadata
extra_parts = []
if paper.get('pmid'):
extra_parts.append(f'PMID: {paper["pmid"]}')
if paper.get('pmcid'):
extra_parts.append(f'PMCID: {paper["pmcid"]}')
if paper.get('pubtype'):
pub_types = paper['pubtype'] if isinstance(paper['pubtype'], str) else ', '.join(paper['pubtype'])
extra_parts.append(f'Publication Type: {pub_types}')
if extra_parts:
item['extra'] = '\n'.join(extra_parts)
return item
def save_items(items, uri=''):
"""Push items to Zotero via saveItems API. Returns (status, msg, session_id).
Uses deterministic sessionID (content hash) for idempotency:
- 201 = saved successfully
- 409 = same items already saved in this Zotero session (success)
"""
session_id = make_session_id(items)
for i, item in enumerate(items):
if 'id' not in item:
item['id'] = f'pm_{session_id}_{i}'
data = {
'sessionID': session_id,
'uri': uri,
'items': items
}
status, resp = zotero_request('saveItems', data)
if status == 201:
msg = f'Saved successfully (session: {session_id})'
elif status == 409:
msg = f'Already saved, no duplicate added (session: {session_id})'
elif status == 500:
detail = resp.get('error', '') if resp else ''
if 'libraryEditable' in str(resp):
return 500, 'Target library is read-only.', session_id
return 500, f'Zotero internal error: {detail}', session_id
elif status == 0:
return 0, 'Zotero is not running or connection refused.', session_id
elif status == -1:
return -1, f'Request timeout ({HTTP_TIMEOUT}s)', session_id
else:
return status, f'Unknown error, HTTP {status}', session_id
return 201, msg, session_id
def resolve_pdf_url(paper):
"""Get the best PDF URL from paper data."""
pdf_url = paper.get('pdfUrl') or paper.get('fullTextUrl') or ''
if pdf_url:
return pdf_url
if paper.get('pmcid'):
pmcid = paper['pmcid']
if not pmcid.startswith('PMC'):
pmcid = f'PMC{pmcid}'
return f'https://www.ncbi.nlm.nih.gov/pmc/articles/{pmcid}/pdf/'
return ''
def download_pdf(pdf_url, timeout=PDF_DOWNLOAD_TIMEOUT):
"""Download PDF from URL. Returns (bytes, error_msg|None)."""
req = urllib.request.Request(pdf_url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': 'application/pdf,*/*',
})
try:
resp = urllib.request.urlopen(req, timeout=timeout)
data = resp.read()
content_type = resp.headers.get('Content-Type', '')
if len(data) < 1024:
return None, f'Too small ({len(data)} bytes), likely a redirect page'
if data[:5] != b'%PDF-' and 'application/pdf' not in content_type:
return None, f'Not a PDF (Content-Type: {content_type})'
return data, None
except urllib.error.HTTPError as e:
return None, f'HTTP {e.code}'
except urllib.error.URLError as e:
return None, f'URL error: {e.reason}'
except TimeoutError:
return None, f'Download timeout ({timeout}s)'
except Exception as e:
return None, str(e)
def save_attachment(session_id, parent_item_id, pdf_bytes, pdf_url,
title='Full Text PDF'):
"""Upload PDF binary to Zotero via /connector/saveAttachment.
Body = raw PDF bytes. Metadata in X-Metadata header.
parentItemID links the attachment to the parent item from saveItems.
sessionID must match the saveItems session.
"""
metadata = json.dumps({
'id': parent_item_id + '_pdf',
'parentItemID': parent_item_id,
'title': title,
'url': pdf_url,
'contentType': 'application/pdf',
}, ensure_ascii=False)
url = f'{ZOTERO_API}/saveAttachment?sessionID={session_id}'
req = urllib.request.Request(url, data=pdf_bytes, headers={
'Content-Type': 'application/pdf',
'X-Metadata': metadata,
'Content-Length': str(len(pdf_bytes)),
})
try:
resp = urllib.request.urlopen(req, timeout=60)
return resp.status, resp.read().decode('utf-8', errors='replace')
except urllib.error.HTTPError as e:
return e.code, e.read().decode('utf-8', errors='replace')
except urllib.error.URLError:
return 0, 'Connection refused'
except TimeoutError:
return -1, 'Timeout'
def main():
"""Main entry point. Accepts JSON paper data from file argument."""
if len(sys.argv) > 1 and sys.argv[1] == '--list':
list_collections()
return
# Check Zotero is running
status, _ = zotero_request('ping')
if status == 0:
print('Error: Zotero is not running. Please start Zotero desktop.')
sys.exit(1)
# Show current collection
col = get_selected_collection()
if col:
print(f'Zotero collection: {col.get("name", "?")}')
# Read paper data from file argument
if len(sys.argv) > 1 and sys.argv[1] != '--list':
with open(sys.argv[1], 'r', encoding='utf-8') as f:
paper_data = json.load(f)
else:
paper_data = json.load(sys.stdin)
# Handle both single paper and array
if isinstance(paper_data, list):
papers = paper_data
elif 'items' in paper_data:
status, msg, _ = save_items(paper_data['items'], paper_data.get('uri', ''))
if status == 201:
print(f'Success: {msg} ({len(paper_data["items"])} papers)')
else:
print(f'Failed: {msg}')
sys.exit(1)
return
else:
papers = [paper_data]
# Build Zotero items
items = []
for p in papers:
if 'itemType' in p:
items.append(p)
else:
items.append(build_zotero_item(p))
if not items:
print('Error: No valid paper data.')
sys.exit(1)
# Step 1: Save metadata
uri = f'https://pubmed.ncbi.nlm.nih.gov/{papers[0].get("pmid", "")}/' if papers[0].get('pmid') else ''
status, msg, session_id = save_items(items, uri)
if status != 201:
print(f'Failed: {msg}')
sys.exit(1)
print(f'Success: {msg} ({len(items)} papers)')
for item in items:
print(f' - {item.get("title", "?")}')
# Steps 2 & 3: Download PDFs and attach to Zotero items
pdf_ok = 0
pdf_fail = 0
for i, (paper, item) in enumerate(zip(papers, items)):
pdf_url = resolve_pdf_url(paper)
if not pdf_url:
continue
item_id = item.get('id', f'pm_{session_id}_{i}')
pdf_bytes, err = download_pdf(pdf_url)
if not pdf_bytes:
print(f' PDF skip: {err} ({pdf_url[:80]})')
pdf_fail += 1
continue
att_status, att_msg = save_attachment(session_id, item_id, pdf_bytes, pdf_url)
if att_status in (200, 201):
size_mb = len(pdf_bytes) / 1024 / 1024
print(f' PDF attached ({size_mb:.1f} MB): {item.get("title", "?")[:60]}')
pdf_ok += 1
else:
print(f' PDF attach failed ({att_status}): {att_msg[:100]}')
pdf_fail += 1
if pdf_ok > 0 or pdf_fail > 0:
print(f'PDFs: {pdf_ok} attached, {pdf_fail} failed')
if __name__ == '__main__':
main()