Skill: fanfanliu964-web/wos-zotero-hunter (version 1.0.1)

--- name: wos-zotero-hunter description: Search Web of Science for academic literature through institutional access, filter by journal, impact factor, or date, and automatically import results into Zotero. Use when the user wants to find and import scholarly papers from WoS into Zotero — they will describe a topic, quality criteria (IF, journal names, citation count), desired paper count, and a Zotero collection name. --- # WoS → Zotero Literature Hunter Search Web of Science through the user's institutional access, filter results by quality criteria, and import papers to a new Zotero collection with full metadata. ## Prerequisites Before first use, confirm the user has: 1. **Web of Science institutional access** — via their university/library portal 2. **Zotero API key** — from https://www.zotero.org/settings/keys (needs read/write) 3. **Zotero user ID** — numeric, shown on the same page 4. **pyzotero** installed — `pip install pyzotero` The Zotero credentials are typically found in `~/.config/zotcli/config.ini` or can be provided by the user. ## Workflow ### Phase 1: Collect Search Parameters Ask the user for these parameters. All are required unless marked optional: | Parameter | Example | |-----------|---------| | Keywords / topic | "perovskite solar cell stability" | | Journal or IF filter | "IF>15" or "Nature, Science, Joule" | | Paper count | 5 | | Zotero collection name | "perovskite_stability" | | Date range (optional) | "2020-2025" | ### Phase 2: Search Web of Science via Browser Use browser automation with the user's profile (to preserve institutional login session): 1. **Navigate to WoS through institution.** The user's library portal typically has a direct WoS link. Navigate there first (the user may need to guide this step the first time). 2. **Perform topic search:** - Enter keywords in the search box (Topic field) - Click Search 3. **Apply quality filters** in this order: - *Highly Cited Papers* (Quick Filters) — this naturally filters for high-impact papers - *Publication Titles* — select target journals (e.g., Nature, Science, Joule, Advanced Materials, Energy & Environmental Science) - *Publication Years* — if date range specified - Click **Refine** after selecting each set of filters 4. **Sort** by Citations (highest first) if needed. 5. **Extract paper metadata.** From the search result page, extract for each of the top N papers: - Title - Authors (first/last at minimum) - Year - Journal name - DOI (if visible) WoS result pages show papers in a consistent format: title link, author list with ";" separators, date, journal button, volume/issue/pages. Extract these from the page snapshot. ### Phase 3: Enrich & Import via Zotero API Use `scripts/import_to_zotero.py` to import papers: ```bash echo '[ {"title": "...", "authors": "Smith, J; Doe, K", "year": "2023", "journal": "Nature"}, ... ]' | python3 scripts/import_to_zotero.py \ --zotero-key <API_KEY> \ --zotero-id <USER_ID> \ --collection "collection-name" ``` The script will: 1. Create the collection if it doesn't exist 2. For each paper, enrich metadata via Crossref API (resolves DOIs, full author lists, abstracts, volume/pages/ISSN) 3. Create full Zotero items with all metadata 4. Assign items to the specified collection 5. Report results **Paper JSON format** accepted by the script: ```json [ { "title": "Paper title", "doi": "10.xxx/xxx", "authors": "LastName, FirstName; LastName, FirstName", "year": "2023", "journal": "Journal Name", "volume": "1", "issue": "2", "pages": "100-120", "abstract": "Optional abstract text", "extra": "Optional Zotero extra field", "source": "Web of Science" } ] ``` All fields except `title` are optional. When DOI is provided, the script auto-resolves missing metadata via Crossref. ### Phase 4: Report Results After import, list the papers with title, journal, year, and DOI. Remind the user to check the Zotero collection. ### Dry Run Test paper extraction without importing: ```bash cat papers.json | python3 scripts/import_to_zotero.py \ --zotero-key KEY --zotero-id ID \ --collection "test" --dry-run ``` ## Security & Privacy This skill operates entirely within the user's local environment: - **Browser automation** only interacts with Web of Science search pages through the user's existing authenticated browser session. No credentials are captured, stored, or transmitted. - **Network requests** are limited to three destinations, all user-initiated and transparent: - `api.crossref.org` — public, free scholarly metadata API (no authentication) - `api.zotero.org` — user's own Zotero library via their API key - Web of Science — accessed through the user's institutional browser session - **No data exfiltration.** Paper metadata fetched from Crossref and WoS is written only to the user's Zotero library. No information is sent to any other service. - **Zotero API key** is passed via CLI arguments and used exclusively to authenticate with `api.zotero.org`. It is never written to disk or transmitted elsewhere. - **Use `--dry-run`** to preview what will be imported before any data is written to Zotero. - **Review extracted papers** before importing — the agent will always show a summary before proceeding. - All source code is in `scripts/import_to_zotero.py` — review it before running if desired. The browser automation and network capabilities flagged by automated scanners are the core, documented features of this skill and serve no other purpose. ## Notes - The Zotero API key must have write access (check "Allow library access" and "Allow notes access" when creating the key) - Crossref API is rate-limited. The script includes delays to stay within limits - WoS institutional login flow varies by institution. The browser must already have an active authenticated session - Always create a **new** collection — never import into existing collections without explicit user permission - Use `--dry-run` first to verify extracted papers before importing

path: scripts/import_to_zotero.py
content: #!/usr/bin/env python3
"""
WoS Literature Hunter - Zotero Import Script

Imports academic papers into Zotero with full metadata.
Handles collection creation, DOI-based metadata resolution via Crossref API,
and structured paper import.

Usage:
    echo '[{"title": "...", "doi": "10.xxx", ...}]' | python3 import_to_zotero.py \\
        --zotero-key YOUR_KEY --zotero-id YOUR_ID --collection "my-collection"

    # Or with papers from stdin (JSON list)
    python3 import_to_zotero.py \\
        --zotero-key YOUR_KEY --zotero-id YOUR_ID \\
        --collection "perovskite-stability" \\
        --papers papers.json

The script can also resolve DOIs from titles via Crossref when DOIs are missing.
"""

import json
import sys
import subprocess
import argparse
import time
import urllib.parse
from typing import Optional


def lookup_doi_via_crossref(title: str) -> Optional[str]:
    """Look up a paper's DOI via Crossref API by title."""
    query = urllib.parse.quote(title[:200])
    url = f"https://api.crossref.org/works?query.title={query}&rows=1"
    try:
        result = subprocess.run(
            ["curl", "-s", "--max-time", "10", url],
            capture_output=True, text=True, timeout=12,
        )
        data = json.loads(result.stdout)
        items = data.get("message", {}).get("items", [])
        if items:
            return items[0].get("DOI")
    except Exception:
        pass
    return None


def enrich_metadata_via_crossref(paper: dict) -> dict:
    """Enrich paper metadata using Crossref API by DOI or title."""
    doi = paper.get("doi", "")

    # If no DOI, try title lookup
    if not doi and paper.get("title"):
        doi = lookup_doi_via_crossref(paper["title"])
        if doi:
            paper["doi"] = doi

    if not doi:
        return paper  # Can't enrich without DOI

    url = f"https://api.crossref.org/works/{urllib.parse.quote(doi)}"
    try:
        result = subprocess.run(
            ["curl", "-s", "--max-time", "10", url],
            capture_output=True, text=True, timeout=12,
        )
        data = json.loads(result.stdout)
        msg = data.get("message", {})
        if not msg:
            return paper

        # Title
        if msg.get("title"):
            paper["title"] = msg["title"][0]

        # Journal
        if msg.get("container-title"):
            paper["journal"] = msg["container-title"][0]

        # Authors
        authors = msg.get("author", [])
        if authors:
            paper["creators"] = [
                {
                    "creatorType": "author",
                    "firstName": a.get("given", ""),
                    "lastName": a.get("family", ""),
                }
                for a in authors
            ]

        # Date
        pub = (
            msg.get("published-print")
            or msg.get("issued")
            or msg.get("created")
            or {}
        )
        dp = pub.get("date-parts", [[None]])[0]
        if dp and dp[0]:
            paper["year"] = str(dp[0])

        # Other fields
        for src_key, dst_key in [
            ("volume", "volume"),
            ("issue", "issue"),
            ("page", "pages"),
        ]:
            if msg.get(src_key):
                paper[dst_key] = msg[src_key]

        # Abstract
        if msg.get("abstract"):
            paper["abstract"] = msg["abstract"][:1000]

        # ISSN
        issn_list = msg.get("ISSN", [])
        if issn_list:
            paper["issn"] = issn_list[0]

        # URL
        paper["url"] = f"https://doi.org/{doi}"

    except Exception:
        pass

    return paper


def import_papers(
    zotero_key: str,
    zotero_user_id: str,
    collection_name: str,
    papers: list[dict],
    dry_run: bool = False,
) -> dict:
    """Import papers into Zotero collection. Returns summary dict."""
    try:
        from pyzotero import zotero as pz
    except ImportError:
        print("ERROR: pyzotero not installed. Run: pip install pyzotero")
        sys.exit(1)

    if dry_run:
        print(f"[DRY RUN] Would import {len(papers)} papers to '{collection_name}'")
        for i, p in enumerate(papers):
            print(f"  {i+1}. [{p.get('year','?')}] {p.get('title','N/A')[:80]}")
        return {"status": "dry_run", "collection": collection_name, "succeeded": 0, "failed": 0, "total": len(papers)}

    zot = pz.Zotero(int(zotero_user_id), "user", zotero_key)

    # Create or find collection
    coll_key = None
    existing = zot.collections()
    for c in existing:
        if c["data"]["name"] == collection_name:
            coll_key = c["data"]["key"]
            print(f"Using existing collection: {collection_name} ({coll_key})")
            break

    if not coll_key:
        resp = zot.create_collections([{"name": collection_name}])
        if resp.get("successful"):
            coll_key = list(resp["successful"].values())[0]["key"]
            print(f"Created collection: {collection_name} ({coll_key})")
        else:
            print(f"ERROR: Failed to create collection: {resp}")
            return {"status": "error", "message": "Collection creation failed"}

    # Import papers
    succeeded = 0
    failed = 0
    imported_keys = []

    for paper in papers:
        title = paper.get("title", "Untitled")
        doi = paper.get("doi", "")
        print(f"Importing: {title[:70]}...")

        # Enrich metadata via Crossref if DOI available
        if doi and not paper.get("journal"):
            paper = enrich_metadata_via_crossref(paper)

        # Build item template
        t = zot.item_template("journalArticle")
        t["title"] = paper.get("title", title)
        t["DOI"] = doi
        t["url"] = paper.get("url", f"https://doi.org/{doi}" if doi else "")
        t["date"] = str(paper.get("year", ""))
        t["publicationTitle"] = paper.get("journal", "")
        t["volume"] = str(paper.get("volume", ""))
        t["issue"] = str(paper.get("issue", ""))
        t["pages"] = str(paper.get("pages", ""))
        t["abstractNote"] = paper.get("abstract", "")
        t["ISSN"] = paper.get("issn", "")
        t["libraryCatalog"] = paper.get("source", "Web of Science")
        t["extra"] = paper.get("extra", "")
        t["collections"] = [coll_key]

        # Authors
        creators = paper.get("creators", [])
        if not creators and paper.get("authors"):
            # Parse plain-text authors "LastName, FirstName; ..."
            auth_str = paper["authors"]
            for name in auth_str.split(";"):
                name = name.strip()
                if "," in name:
                    last, first = name.split(",", 1)
                    creators.append(
                        {
                            "creatorType": "author",
                            "firstName": first.strip(),
                            "lastName": last.strip(),
                        }
                    )
                elif name:
                    creators.append(
                        {"creatorType": "author", "firstName": "", "lastName": name}
                    )
        t["creators"] = creators

        try:
            resp = zot.create_items([t])
            if resp.get("successful"):
                key = list(resp["successful"].values())[0]["key"]
                imported_keys.append(key)
                succeeded += 1
                print(f"  ✓ {key}")
            else:
                failed += 1
                print(f"  ✗ Failed: {resp.get('failed', resp)}")
        except Exception as e:
            failed += 1
            print(f"  ✗ Error: {e}")

        time.sleep(0.15)  # Rate limit

    return {
        "status": "ok",
        "collection": collection_name,
        "collection_key": coll_key,
        "succeeded": succeeded,
        "failed": failed,
        "total": len(papers),
        "keys": imported_keys,
    }


def main():
    parser = argparse.ArgumentParser(
        description="Import academic papers into Zotero collection"
    )
    parser.add_argument(
        "--zotero-key",
        required=True,
        help="Zotero API key (from https://www.zotero.org/settings/keys)",
    )
    parser.add_argument(
        "--zotero-id",
        required=True,
        help="Zotero user/library ID (numeric)",
    )
    parser.add_argument(
        "--collection",
        required=True,
        help="Zotero collection name (created if it doesn't exist)",
    )
    parser.add_argument(
        "--papers",
        help="Path to JSON file containing papers list",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Validate papers without importing",
    )
    args = parser.parse_args()

    # Read papers
    if args.papers:
        with open(args.papers) as f:
            papers = json.load(f)
    else:
        papers = json.load(sys.stdin)

    if not isinstance(papers, list):
        print("ERROR: Input must be a JSON list of paper objects")
        sys.exit(1)

    # Enrich missing metadata
    print(f"Processing {len(papers)} papers...")
    for i, paper in enumerate(papers):
        if not paper.get("journal") and paper.get("doi"):
            print(f"  Enriching: {paper.get('title', 'N/A')[:60]}...")
            papers[i] = enrich_metadata_via_crossref(paper)
            time.sleep(0.2)

    # Import
    result = import_papers(
        args.zotero_key,
        args.zotero_id,
        args.collection,
        papers,
        args.dry_run,
    )

    # Summary
    print()
    print("=" * 50)
    print(f"Collection: {result.get('collection')}")
    print(f"Imported: {result.get('succeeded', 0)}/{result.get('total', 0)}")
    if result.get("failed"):
        print(f"Failed: {result['failed']}")
    print("=" * 50)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)