
Cnki Export
- 313 installs
- 811 repo stars
- Updated March 13, 2026
- cookjohn/cnki-skills
Export bibliographic records and full-text artifacts from CNKI during literature reviews, thesis prep, or competitive academic intelligence gathering.
About
cnki-export from cookjohn/cnki-skills enables Claude Code agents to query and export China National Knowledge Infrastructure results, capturing citations, abstracts, and downloadable full text for systematic reviews, grant writing, and early-domain research workflows.
- CNKI record export automation
- Bibliographic metadata extraction
- Batch download workflows
- Academic literature review support
- Agent-driven research pipelines
Cnki Export by the numbers
- 313 all-time installs (skills.sh)
- Ranked #487 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cookjohn/cnki-skills --skill cnki-exportAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 313 |
|---|---|
| repo stars | ★ 811 |
| Last updated | March 13, 2026 |
| Repository | cookjohn/cnki-skills ↗ |
What it does
Export bibliographic records and full-text artifacts from CNKI during literature reviews, thesis prep, or competitive academic intelligence gathering.
Files
CNKI Export & Zotero Integration
Export paper citation data from CNKI and push directly to Zotero, or save as RIS file.
Arguments
zotero(default) — push to Zotero desktop via local APIris— save as .ris filegb— output GB/T 7714 citation text- Optionally include a paper URL
Mode Selection
Choose the right mode based on context:
| Context | Mode | Tool calls |
|---|---|---|
| On a paper detail page | Single export (Step 1A) | 1 evaluate + 1 bash = 2 |
| On a search results page, save all/selected | Batch export (Step 1B) | 1 evaluate + 1 bash = 2 |
| Need to search then save | Use cnki-search first, then batch export | 4 total |
Always prefer batch export (1B) when multiple papers need saving. It avoids navigating to each detail page (saves ~3 calls per paper).
Steps
1A. Single export: from paper detail page
Use mcp__chrome-devtools__evaluate_script:
async () => {
const url = document.querySelector('#export-url')?.value;
const params = document.querySelector('#export-id')?.value;
const uniplatform = new URLSearchParams(window.location.search).get('uniplatform') || 'NZKPT';
if (!url || !params) return { error: 'Not on a paper detail page' };
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ filename: params, displaymode: 'GBTREFER,elearning,EndNote', uniplatform })
});
const data = await resp.json();
if (data.code !== 1) return { error: data.msg };
const result = {};
for (const item of data.data) {
result[item.mode] = item.value[0];
}
const body = document.body.innerText;
result.pageUrl = window.location.href;
result.issn = body.match(/ISSN[::]\s*(\S+)/)?.[1] || '';
result.dbcode = document.querySelector('#paramdbcode')?.value || '';
result.dbname = document.querySelector('#paramdbname')?.value || '';
result.filename = document.querySelector('#paramfilename')?.value || '';
return result;
}1B. Batch export: from search results page (PREFERRED for multiple papers)
On any CNKI search results page, extract checkbox values and call the export API directly — no need to navigate to detail pages.
Key discovery: input.cbItem checkbox value === detail page #export-id (same encrypted ID).
Use mcp__chrome-devtools__evaluate_script:
async () => {
const API_URL = 'https://kns.cnki.net/dm8/API/GetExport';
// Get all checkbox values (= export encrypted IDs)
const checkboxes = document.querySelectorAll('.result-table-list tbody input.cbItem');
const rows = document.querySelectorAll('.result-table-list tbody tr');
if (checkboxes.length === 0) return { error: 'No results on page' };
const allPapers = [];
for (let i = 0; i < checkboxes.length; i++) {
const exportId = checkboxes[i].value;
const paperUrl = rows[i]?.querySelector('td.name a.fz14')?.href || '';
const resp = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ filename: exportId, displaymode: 'GBTREFER,elearning,EndNote', uniplatform: 'NZKPT' })
});
const data = await resp.json();
if (data.code === 1) {
const result = {};
for (const item of data.data) { result[item.mode] = item.value[0]; }
result.pageUrl = paperUrl;
// Extract ISSN from ENDNOTE %@ field
const issnMatch = result.ENDNOTE?.match(/%@\s*([^\s<]+)/);
result.issn = issnMatch ? issnMatch[1] : '';
result.dbcode = 'CJFQ';
result.dbname = '';
result.filename = '';
allPapers.push(result);
}
}
return allPapers; // JSON array, directly writable to file for Python script
}To export only specific papers (e.g. #1, #3, #5), filter by index:
// Replace the for loop condition:
const indices = [0, 2, 4]; // 0-indexed: papers #1, #3, #5
for (let i = 0; i < checkboxes.length; i++) {
if (!indices.includes(i)) continue;
// ... rest same
}2. Push to Zotero
Save the export data (single object or JSON array) to a temp file, then run the Python script:
python "e:/cnki/.claude/skills/cnki-export/scripts/push_to_zotero.py" /tmp/papers.jsonThe Python script handles both single paper {} and batch [{}, {}, ...] JSON input.
- UTF-8 encoding (avoids Windows encoding issues)
- Parsing ELEARNING format into Zotero item fields
- Calling
POST http://127.0.0.1:23119/connector/saveItems - Returns: 201 = success, 500 = error, 0 = Zotero not running
3. Report result
Single:
已将论文添加到 Zotero:
标题: {title}
作者: {authors}
期刊: {journal}
GB/T 7714 引用: {gbt_citation}Batch:
已批量添加 {count} 篇论文到 Zotero:
1. {title1} ({journal1})
2. {title2} ({journal2})
...Export API Reference
| Parameter | Value | Source |
|---|---|---|
| API URL | https://kns.cnki.net/dm8/API/GetExport | Fixed, works from any page |
| filename | Encrypted ID | Detail page: #export-id; Results page: input.cbItem value |
| displaymode | GBTREFER,elearning,EndNote | Comma-separated modes |
| uniplatform | NZKPT | Required |
Verified selectors
| Element | Selector | Page |
|---|---|---|
| Export URL | #export-url | Detail page only |
| Export ID | #export-id | Detail page only |
| Checkbox (= export ID) | input.cbItem | Search results page |
| Result rows | .result-table-list tbody tr | Search results page |
| Title link | td.name a.fz14 | Search results page |
Zotero API Reference
POST http://127.0.0.1:23119/connector/saveItems
Content-Type: application/json
X-Zotero-Connector-API-Version: 3Response: 201 = created, 500 = error Collection: Saves to Zotero's currently selected collection.
Query collections:
python "e:/cnki/.claude/skills/cnki-export/scripts/push_to_zotero.py" --listImportant Notes
- Windows encoding: Must use Python script, cannot pass Chinese JSON via bash/curl directly
- Zotero must be running:
localhost:23119requires Zotero desktop in background - Chinese authors: Use
namefield (single field, not split),creatorType: "author" - Batch export saves ~90% tool calls: 9 papers: 33 calls → 3 calls
- CNKI Export API:
filenamemust be encrypted ID (#export-idorinput.cbItemvalue), NOT#paramfilename
#!/usr/bin/env python3
"""Push CNKI 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)
- Zotero's session gc/remove are buggy, sessions persist until restart.
Deterministic IDs turn this bug into a feature: same content → same ID → 409 = already done.
"""
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 # seconds, matching Zotero Connector extension
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'请求超时 ({timeout}s)'}
def make_session_id(items):
"""Generate deterministic sessionID from item content (titles hash).
Same items always produce the same ID, so:
- First call: creates session, saves items → 201
- Repeat call: session exists → 409 → treat as already saved
"""
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: 无法连接 Zotero。请确保 Zotero 桌面端已启动。')
return
print(f'当前选中分类: {data.get("name", "?")} (ID: {data.get("id", "?")})')
print(f'文库: {data.get("libraryName", "?")}')
print()
print('可用分类:')
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_elearning(text):
"""Parse CNKI ELEARNING export format into structured fields."""
text = text.replace('<br>', '\n').replace('\r', '')
text = re.sub(r'<[^>]+>', '', text) # strip HTML tags
def get(key):
m = re.search(rf'{re.escape(key)}:\s*(.+?)(?=\n|$)', text)
return m.group(1).strip() if m else ''
return {
'title': get('Title-题名'),
'authors': [a.strip() for a in get('Author-作者').split(';') if a.strip()],
'journal': get('Source-刊名'),
'year': get('Year-年'),
'pubTime': get('PubTime-出版时间'),
'keywords': [k.strip() for k in get('Keyword-关键词').split(';') if k.strip()],
'abstract': get('Summary-摘要'),
'volume': get('Roll-卷'),
'issue': get('Period-期'),
'pageCount': get('PageCount-页数'),
'pages': get('Page-页码'),
'organs': get('Organ-机构'),
'link': get('Link-链接'),
'srcDb': get('SrcDatabase-来源库'),
}
def build_zotero_item(paper):
"""Build Zotero item JSON from paper data (matching Zotero Connector output)."""
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
item = {
'itemType': 'journalArticle',
'title': paper.get('title', ''),
'abstractNote': paper.get('abstract', ''),
'date': paper.get('pubTime') or paper.get('year', ''),
'language': 'zh-CN',
'libraryCatalog': 'CNKI',
'accessDate': now,
'volume': paper.get('volume', ''),
'pages': paper.get('pages', ''),
'publicationTitle': paper.get('journal', ''),
'issue': paper.get('issue', ''),
'creators': [{'name': a, 'creatorType': 'author'} for a in paper.get('authors', [])],
'tags': [{'tag': k, 'type': 1} for k in paper.get('keywords', [])],
'attachments': [],
}
# URL: use Zotero Connector's format for compatibility
dbcode = paper.get('dbcode', '')
dbname = paper.get('dbname', '')
filename = paper.get('filename', '')
if dbcode and dbname and filename:
item['url'] = f'https://kns.cnki.net/KCMS/detail/detail.aspx?dbcode={dbcode}&dbname={dbname}&filename={filename}'
elif paper.get('link'):
item['url'] = paper['link']
# ISSN
if paper.get('issn'):
item['ISSN'] = paper['issn']
# Build extra field (matching Zotero Connector's CNKI translator output)
extra_parts = []
if paper.get('journalEN'):
extra_parts.append(f'original-container-title: {paper["journalEN"]}')
if paper.get('foundation'):
extra_parts.append(f'foundation: {paper["foundation"]}')
if paper.get('downloadCount'):
extra_parts.append(f'download: {paper["downloadCount"]}')
if paper.get('album'):
extra_parts.append(f'album: {paper["album"]}')
if paper.get('clcCode'):
extra_parts.append(f'CLC: {paper["clcCode"]}')
if dbcode:
extra_parts.append(f'dbcode: {dbcode}')
if dbname:
extra_parts.append(f'dbname: {dbname}')
if filename:
extra_parts.append(f'filename: {filename}')
if paper.get('publicationTag'):
extra_parts.append(f'publicationTag: {paper["publicationTag"]}')
if paper.get('cif'):
extra_parts.append(f'CIF: {paper["cif"]}')
if paper.get('aif'):
extra_parts.append(f'AIF: {paper["aif"]}')
if extra_parts:
item['extra'] = '\n'.join(extra_parts)
return item
def download_pdf(pdf_url, cookies='', referer='https://kns.cnki.net'):
"""Download PDF from CNKI using provided cookies. Returns (bytes, content_type) or (None, error)."""
req = urllib.request.Request(pdf_url, headers={
'Cookie': cookies,
'Referer': referer,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0',
})
try:
resp = urllib.request.urlopen(req, timeout=60)
content_type = resp.headers.get('Content-Type', 'application/pdf')
data = resp.read()
if len(data) < 1024:
return None, f'PDF 文件太小 ({len(data)} bytes),可能需要登录'
return data, content_type
except Exception as e:
return None, str(e)
def save_attachment(session_id, item_id, pdf_bytes, pdf_url, content_type='application/pdf', title='Full Text PDF'):
"""Upload PDF binary to Zotero via /connector/saveAttachment (Zotero 7.x workflow)."""
metadata = json.dumps({
'id': item_id + '_pdf',
'parentItemID': item_id,
'title': title,
'url': pdf_url,
'contentType': content_type,
})
url = f'{ZOTERO_API}/saveAttachment?sessionID={session_id}'
req = urllib.request.Request(url, data=pdf_bytes, headers={
'Content-Type': content_type,
'X-Metadata': metadata,
'Content-Length': str(len(pdf_bytes)),
'X-Zotero-Connector-API-Version': '3',
})
try:
resp = urllib.request.urlopen(req, timeout=60)
return resp.status, None
except urllib.error.HTTPError as e:
return e.code, e.read().decode('utf-8', errors='replace')
except Exception as e:
return 0, str(e)
def save_items(items, uri='', attachments=None, cookies=''):
"""Push items to Zotero via saveItems API, optionally with PDF attachments.
Uses deterministic sessionID (content hash) for idempotency:
- 201 = saved successfully
- 409 = same items already saved in this Zotero session (success)
If attachments are provided, downloads and uploads PDFs after saving metadata.
attachments format: [{"itemIndex": 0, "pdfUrl": "https://...", "title": "Full Text PDF"}, ...]
"""
session_id = make_session_id(items)
# Assign IDs to items (needed for attachment parentItemID mapping)
for i, item in enumerate(items):
if 'id' not in item:
item['id'] = f'cnki_{session_id}_{i}'
data = {
'sessionID': session_id,
'uri': uri,
'items': items
}
status, resp = zotero_request('saveItems', data)
already_saved = False
if status == 201:
msg = f'保存成功 (session: {session_id})'
elif status == 409:
already_saved = True
msg = f'这批论文已保存过,无需重复添加 (session: {session_id})'
elif status == 500:
detail = resp.get('error', '') if resp else ''
if 'libraryEditable' in str(resp):
return 500, '目标文库为只读,请在 Zotero 中切换到可写的分类'
return 500, f'Zotero 内部错误: {detail}'
elif status == 0:
return 0, 'Zotero 未运行或连接被拒绝'
elif status == -1:
return -1, f'请求超时 ({HTTP_TIMEOUT}s),Zotero 可能正在处理大量数据'
else:
return status, f'未知错误,HTTP {status}'
# Handle PDF attachments (only for new saves, skip if already saved)
if attachments and not already_saved:
# Check if target collection supports files
col = get_selected_collection()
files_editable = col.get('filesEditable', True) if col else True
if files_editable:
pdf_results = []
for att in attachments:
idx = att.get('itemIndex', 0)
pdf_url = att.get('pdfUrl', '')
title = att.get('title', 'Full Text PDF')
if not pdf_url:
continue
item_id = items[idx]['id'] if idx < len(items) else items[0]['id']
print(f' 下载 PDF: {pdf_url[:80]}...', file=sys.stderr)
pdf_bytes, ct = download_pdf(pdf_url, cookies=cookies)
if pdf_bytes is None:
pdf_results.append(f' PDF 下载失败: {ct}')
continue
print(f' 上传 PDF 到 Zotero ({len(pdf_bytes)} bytes)...', file=sys.stderr)
att_status, att_err = save_attachment(session_id, item_id, pdf_bytes, pdf_url, title=title)
if att_status == 201:
pdf_results.append(f' PDF 已附加: {title} ({len(pdf_bytes) // 1024}KB)')
else:
pdf_results.append(f' PDF 上传失败: HTTP {att_status} {att_err or ""}')
if pdf_results:
msg += '\n' + '\n'.join(pdf_results)
else:
msg += '\n (目标分类不支持文件附件,跳过 PDF)'
return 201, msg
def main():
"""Main entry point. Accepts JSON paper data from stdin or 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 未运行。请启动 Zotero 桌面端。')
sys.exit(1)
# Show current collection
col = get_selected_collection()
if col:
print(f'Zotero 当前分类: {col.get("name", "?")}')
# Read paper data from stdin or file
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:
# Already in Zotero format
status, msg = save_items(paper_data['items'], paper_data.get('uri', ''))
if status == 201:
print(f'成功: {msg} ({len(paper_data["items"])} 篇)')
else:
print(f'失败: {msg}')
sys.exit(1)
return
else:
papers = [paper_data]
# Build Zotero items
items = []
for p in papers:
if 'itemType' in p:
items.append(p)
elif 'title' in p and 'authors' in p:
items.append(build_zotero_item(p))
elif 'ELEARNING' in p:
parsed = parse_elearning(p['ELEARNING'])
# Merge page-level fields into parsed data
for k in ['issn', 'dbcode', 'dbname', 'filename', 'clcCode',
'journalEN', 'foundation', 'downloadCount', 'album',
'publicationTag', 'cif', 'aif', 'pageUrl']:
if k in p and p[k]:
parsed[k] = p[k]
items.append(build_zotero_item(parsed))
if not items:
print('Error: 无有效论文数据。')
sys.exit(1)
# Collect attachment info and cookies from input
attachments = []
cookies = ''
for i, p in enumerate(papers):
if p.get('pdfUrl'):
attachments.append({
'itemIndex': i,
'pdfUrl': p['pdfUrl'],
'title': p.get('pdfTitle', 'Full Text PDF'),
})
if p.get('cookies') and not cookies:
cookies = p['cookies']
uri = papers[0].get('pageUrl', papers[0].get('link', ''))
status, msg = save_items(items, uri, attachments=attachments, cookies=cookies)
if status == 201:
print(f'成功: {msg} ({len(items)} 篇)')
for item in items:
print(f' - {item.get("title", "?")}')
else:
print(f'失败: {msg}')
sys.exit(1)
if __name__ == '__main__':
main()