
Wos Export
- 19 installs
- 45 repo stars
- Updated June 23, 2026
- yuanyuanma03/academic-research-skills
Export Web of Science records to Zotero, RIS, BibTeX, or Excel, preferring a direct Zotero push from collected metadata.
About
Builds paper metadata from prior WoS search or detail results and pushes it to Zotero, or exports files via the WoS UI. A researcher uses it to save WoS records into a reference manager.
- Preferred no-UI Zotero push via push_to_zotero.py
- Accepts WoS-specific fields like accession number, JIF, and cited counts
Wos Export by the numbers
- 19 all-time installs (skills.sh)
- Ranked #1,308 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yuanyuanma03/academic-research-skills --skill wos-exportAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 45 |
| Last updated | June 23, 2026 |
| Repository | yuanyuanma03/academic-research-skills ↗ |
What it does
Export Web of Science records to Zotero, RIS, BibTeX, or Excel, preferring a direct Zotero push from collected metadata.
Files
WoS Export
Export WoS paper records. Two modes: direct Zotero push (preferred) or file export via UI.
Mode A: Push to Zotero (preferred, no UI interaction)
Collect paper metadata (from prior search/detail results) and push to Zotero via the push_to_zotero.py script.
Step 1: Prepare Paper Data
Build a JSON object from data already available in the conversation (from wos-search API results or wos-paper-detail extraction). The script accepts WoS paper fields directly:
{
"title": "Value co-creation in service logic",
"authors": "Grönroos, C",
"source": "MARKETING THEORY",
"year": 2011,
"volume": "11",
"issue": "3",
"pages": "279-301",
"doi": "10.1177/1470593111408177",
"issn": "1470-5931",
"abstract": "The underpinning logic of...",
"language": "English",
"accessionNumber": "WOS:000295471900004",
"authorKeywords": ["value co-creation", "service logic"],
"keywordsPlus": ["DOMINANT LOGIC"],
"citedCount": "992",
"alldbCited": "1,254",
"jif": "2.8",
"jifYear": "2024",
"jcrQuartile": "Q3",
"researchAreas": "Business & Economics",
"wosCategories": "Business",
"docType": "Article"
}For multiple papers, wrap in an array or {"items": [...]}.
Step 2: Push via Script
echo '{JSON_DATA}' | python scripts/push_to_zotero.pyOr save to temp file first (recommended for large data or Chinese characters):
python scripts/push_to_zotero.py /tmp/wos_export.jsonStep 3: Report Result
OK: Saved (session: xxx)→ successOK: Already saved (session: xxx)→ idempotent, no duplicatesError: Zotero not running→ tell user to start Zotero desktop
The script auto-detects Zotero's currently selected collection. User can change target by selecting a different folder in Zotero before exporting.
To list collections: python push_to_zotero.py --list
Mode B: File Export via UI (fallback)
When Zotero is not available or user specifically wants a file.
Prerequisites
Browser must be on a WoS results page (/summary/...) or full record page (/full-record/...).
Supported Formats
| Format | Menu Item | File |
|---|---|---|
| RIS | "RIS (other reference software)" | .ris |
| BibTeX | "BibTeX" | .bib |
| Excel | "Excel" | .xlsx |
| Plain Text | "Plain text file" | .txt |
| Fast 5000 | "Fast 5000" | Quick export |
Steps
1. take_snapshot → find Export button (button with haspopup="menu" containing "Export") → click 2. take_snapshot → find format menuitem → click 3. Handle export dialog (record range, content options) → click download button
Notes on UI Export
- Uses 3-4 tool calls: snapshot + click Export + snapshot + click format + dialog
- "Fast 5000" exports up to 5000 records with basic fields
- On full-record page, exports only the current record
Zotero Script Features
- Deterministic session ID: Same papers always produce the same session ID (content hash). Prevents duplicates on retry (409 → treated as success).
- WoS metadata mapping: Automatically maps WoS fields to Zotero's journalArticle schema.
- Extra field: Stores WoS-specific data (WoS ID, citation counts, JIF, JCR quartile, research areas) in Zotero's Extra field.
- Author parsing: Handles WoS standard format "LastName, Initials" → Zotero firstName/lastName.
- Keywords: Both Author Keywords and Keywords Plus are added as Zotero tags.
Notes
- Mode A (Zotero push) requires no browser interaction — uses data from prior API calls
- Mode A works even when browser is not on a WoS page
- Script path:
scripts/push_to_zotero.py - Zotero must be running on localhost:23119
#!/usr/bin/env python3
"""Push WoS paper data to Zotero via local Connector API (localhost:23119).
Session strategy: deterministic sessionID derived from content hash.
- 201 = saved successfully
- 409 = SESSION_EXISTS = already saved (idempotent, treat as success)
Input: JSON from stdin or file argument. Accepts either:
1. Raw WoS API record format (from evaluate_script)
2. Pre-built Zotero item format (itemType present)
3. Wrapper with "items" array (direct saveItems format)
"""
import json
import sys
import io
import hashlib
import urllib.request
import urllib.error
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
def zotero_request(endpoint, data=None, timeout=HTTP_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'Timeout ({timeout}s)'}
def make_session_id(items):
key = '|'.join(sorted(item.get('title', '') for item in items))
return hashlib.md5(key.encode('utf-8', errors='surrogateescape')).hexdigest()[:12]
def build_zotero_item(paper):
"""Build Zotero journalArticle item from WoS paper data."""
# Handle authors - accept both string ("A; B; C") and list formats
authors_raw = paper.get('authors', [])
if isinstance(authors_raw, str):
authors_raw = [a.strip() for a in authors_raw.split(';') if a.strip()]
creators = []
for name in authors_raw:
name = name.strip()
if not name:
continue
# WoS standard format: "LastName, FirstInitials" e.g. "Gronroos, C"
if ',' in name:
parts = name.split(',', 1)
creators.append({
'lastName': parts[0].strip(),
'firstName': parts[1].strip(),
'creatorType': 'author'
})
else:
creators.append({'name': name, 'creatorType': 'author'})
# Build date from year + published
date = paper.get('published', '') or str(paper.get('year', ''))
item = {
'itemType': 'journalArticle',
'title': paper.get('title', ''),
'abstractNote': paper.get('abstract', ''),
'date': date,
'language': paper.get('language', 'en'),
'libraryCatalog': 'Web of Science',
'publicationTitle': paper.get('source', ''),
'volume': str(paper.get('volume', '') or ''),
'issue': str(paper.get('issue', '') or ''),
'pages': str(paper.get('pages', '') or ''),
'DOI': paper.get('doi', ''),
'ISSN': paper.get('issn', ''),
'creators': creators,
'tags': [],
'attachments': [],
}
# URL
wos_id = paper.get('accessionNumber', '') or paper.get('wosId', '')
if wos_id:
item['url'] = f'https://www.webofscience.com/wos/woscc/full-record/{wos_id}'
# Keywords as tags
for kw in paper.get('authorKeywords', []):
item['tags'].append({'tag': kw, 'type': 1})
for kw in paper.get('keywordsPlus', []):
item['tags'].append({'tag': kw, 'type': 1})
# Extra field - WoS-specific metadata
extra_parts = []
if wos_id:
extra_parts.append(f'WoS ID: {wos_id}')
cited = paper.get('citedCount', '') or paper.get('citations', '')
if cited:
alldb = paper.get('alldbCited', '') or paper.get('citationsAll', '')
if alldb:
extra_parts.append(f'Cited: {cited} (WOSCC) / {alldb} (All DB)')
else:
extra_parts.append(f'Cited: {cited}')
if paper.get('jif'):
jif_str = f'JIF: {paper["jif"]}'
if paper.get('jifYear'):
jif_str += f' ({paper["jifYear"]})'
extra_parts.append(jif_str)
if paper.get('jcrQuartile'):
extra_parts.append(f'JCR: {paper["jcrQuartile"]}')
if paper.get('researchAreas'):
extra_parts.append(f'Research Areas: {paper["researchAreas"]}')
if paper.get('wosCategories'):
extra_parts.append(f'WoS Categories: {paper["wosCategories"]}')
if paper.get('docType'):
extra_parts.append(f'Document Type: {paper["docType"]}')
if extra_parts:
item['extra'] = '\n'.join(extra_parts)
return item
def save_items(items, uri=''):
session_id = make_session_id(items)
for i, item in enumerate(items):
if 'id' not in item:
item['id'] = f'wos_{session_id}_{i}'
data = {'sessionID': session_id, 'uri': uri, 'items': items}
status, resp = zotero_request('saveItems', data)
if status == 201:
return 201, f'Saved (session: {session_id})'
elif status == 409:
return 201, f'Already saved (session: {session_id})'
elif status == 500:
if resp and 'libraryEditable' in str(resp):
return 500, 'Library is read-only'
return 500, f'Zotero error: {resp}'
elif status == 0:
return 0, 'Zotero not running'
elif status == -1:
return -1, f'Timeout ({HTTP_TIMEOUT}s)'
else:
return status, f'HTTP {status}'
def main():
if len(sys.argv) > 1 and sys.argv[1] == '--list':
status, data = zotero_request('getSelectedCollection')
if status != 200 or not data:
print('Error: Cannot connect to Zotero.')
sys.exit(1)
print(f'Current collection: {data.get("name", "?")}')
for t in data.get('targets', []):
indent = ' ' * t.get('level', 0)
print(f' {indent}{t["name"]} (ID: {t["id"]})')
return
# Check Zotero
status, _ = zotero_request('ping')
if status == 0:
print('Error: Zotero not running.')
sys.exit(1)
col_status, col = zotero_request('getSelectedCollection')
if col_status == 200 and col:
print(f'Zotero collection: {col.get("name", "?")}')
# Read input
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 wrapper format {"items": [...]}
if isinstance(paper_data, dict) and 'items' in paper_data:
items = paper_data['items']
# If items already have itemType, use directly
if items and 'itemType' in items[0]:
status, msg = save_items(items, paper_data.get('uri', ''))
else:
built = [build_zotero_item(p) for p in items]
status, msg = save_items(built, paper_data.get('uri', ''))
if status == 201:
print(f'OK: {msg} ({len(items)} papers)')
else:
print(f'Error: {msg}')
sys.exit(1)
return
# Handle single or array
papers = paper_data if isinstance(paper_data, list) else [paper_data]
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)
uri = papers[0].get('url', '')
status, msg = save_items(items, uri)
if status == 201:
print(f'OK: {msg} ({len(items)} papers)')
for item in items:
print(f' - {item.get("title", "?")}')
else:
print(f'Error: {msg}')
sys.exit(1)
if __name__ == '__main__':
main()