Skill: jirboy/literature-review-cn (version 1.0.0)

--- name: literature-review version: 1.2.0 description: Assistance with writing literature reviews by searching for academic sources via Semantic Scholar, OpenAlex, Crossref and PubMed APIs. Use when the user needs to find papers on a topic, get details for specific DOIs, or draft sections of a literature review with proper citations. --- # Literature Review Help write academic literature reviews using a multi-engine search integration (S2, OA, CR, PM). ## Capabilities - **Multi-Source Search**: Find relevant academic papers using Semantic Scholar (S2), OpenAlex (OA), Crossref (CR), and PubMed (PM). - **Full Abstracts**: All sources now return complete abstracts (PubMed uses `efetch` for full XML records). - **DOI Extraction**: DOIs are extracted from all sources for cross-referencing and deduplication. - **Automatic Deduplication**: When searching multiple sources (`--source all` or `--source both`), results are automatically deduplicated by DOI. - **Polite Access**: Automatic email identification for OpenAlex/Crossref "Polite Pool" (via `USER_EMAIL` env var). - **Abstract Reconstruction**: Reconstructs abstracts from OpenAlex inverted index format. - **Synthesis**: Group papers by theme and draft review sections based on metadata. ## Environment Variables | Variable | Purpose | Default | |----------|---------|---------| | `USER_EMAIL` | Email for polite API access | `[REDACTED_SECRET]` | | `CLAWDBOT_EMAIL` | Fallback if USER_EMAIL not set | — | | `SEMANTIC_SCHOLAR_API_KEY` | Optional S2 API key for higher rate limits | — | | `OPENALEX_API_KEY` | Optional OpenAlex API key | — | ## Workflows ### 1. Broad Search (All Bases) Get a comprehensive overview from all major academic databases. Results are automatically deduplicated by DOI. ```bash python3 scripts/lit_search.py search "impact of glycyrrhiza on bifidobacterium" --limit 5 --source all ``` ### 2. Targeted Search - **OpenAlex** (`oa`): Fast and comprehensive, good abstracts. - **Semantic Scholar** (`s2`): High-quality citation data and TL;DRs. - **Crossref** (`cr`): Precise DOI-based metadata (no abstracts). - **PubMed** (`pm`): Gold standard for biomedical research, full abstracts and PMIDs. ```bash python3 scripts/lit_search.py search "prebiotic effects of liquorice" --source pm ``` ### 3. Comparing Sources Search both S2 and OA simultaneously to ensure nothing is missed. Deduplicated by default. ```bash python3 scripts/lit_search.py search "Bifidobacterium infantis growth" --source both ``` ### 4. Getting Full Details (S2) Retrieve detailed metadata including TL;DR summaries. ```bash python3 scripts/lit_search.py details "DOI:10.1016/j.foodchem.2023.136000" ``` ### 5. Writing the Review 1. **Extract**: Pull key findings from the abstracts found. 2. **Organize**: Group findings into a logical structure (e.g., chronological or thematic). 3. **Draft**: Use the "Think step-by-step" approach to synthesize multiple sources into a coherent narrative. ## Output Format Each result includes: - `id`: Source-specific identifier (PMID for PubMed, OpenAlex ID, S2 paper ID, DOI for Crossref) - `doi`: DOI when available (used for deduplication) - `title`: Paper title - `year`: Publication year - `authors`: List of author names - `abstract`: Full abstract text (when available) - `venue`: Journal or conference name - `citationCount`: Citation count (S2, OA) - `source`: Which database the result came from ## Tips for Success - **Citations**: Always cross-reference the DOI or PMID for accuracy in bibliography. - **Filtering**: Focus on papers with higher `citationCount` or recent years for a more modern review. - **PubMed for Medicine**: Use `--source pm` for the most reliable biomedical literature. - **Deduplication**: Multi-source searches automatically remove duplicates; use single sources if you need raw counts.

path: package.json
content: license: MIT
version: 1.0.0
description: 文献综述辅助技能
name: literature-review
author: OpenClaw Community

path: scripts/lit_search.py
content: #!/usr/bin/env python3
import sys
import json
import requests
import argparse
import os
import time
import xml.etree.ElementTree as ET

S2_BASE_URL = "https://api.semanticscholar.org/graph/v1"
OA_BASE_URL = "https://api.openalex.org"
CR_BASE_URL = "https://api.crossref.org/works"
PM_BASE_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"

# Timeout for all requests (seconds)
REQUEST_TIMEOUT = 30

# Env vars - NO hardcoded emails
S2_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY")
OA_API_KEY = os.getenv("OPENALEX_API_KEY")
USER_EMAIL = os.getenv("USER_EMAIL", os.getenv("CLAWDBOT_EMAIL", "[REDACTED_SECRET]"))

def get_s2_headers():
    headers = {}
    if S2_API_KEY:
        headers["x-api-key"] = S2_API_KEY
    return headers

def get_oa_params(params):
    if OA_API_KEY:
        params["api_key"] = OA_API_KEY
    return params

def get_headers():
    return {"User-Agent": f"Clawdbot/1.0 (mailto:{USER_EMAIL})"}

def safe_get_year(item, *keys):
    """Safely extract year from nested dict structures, handling None/missing fields."""
    for key in keys:
        try:
            val = item.get(key)
            if val is None:
                continue
            if isinstance(val, int):
                return val
            if isinstance(val, str):
                # Try to extract year from string like "2023" or "2023-01-15"
                year_str = val.split("-")[0].split(" ")[0]
                if year_str.isdigit() and len(year_str) == 4:
                    return int(year_str)
            if isinstance(val, dict):
                # Handle Crossref date-parts format
                date_parts = val.get("date-parts")
                if date_parts and isinstance(date_parts, list) and len(date_parts) > 0:
                    first_part = date_parts[0]
                    if isinstance(first_part, list) and len(first_part) > 0 and first_part[0]:
                        return first_part[0]
        except (TypeError, KeyError, IndexError, AttributeError):
            continue
    return None

def search_s2(query, limit=10):
    fields = "title,year,abstract,authors,citationCount,venue,externalIds"
    url = f"{S2_BASE_URL}/paper/search"
    params = {"query": query, "limit": limit, "fields": fields}
    try:
        response = requests.get(url, params=params, headers=get_s2_headers(), timeout=REQUEST_TIMEOUT)
        if response.status_code != 200:
            return {"error": f"HTTP {response.status_code}: {response.text[:200]}", "data": []}
        data = response.json()
        # Normalize output to include doi field for deduplication
        results = []
        for paper in data.get("data", []):
            ext_ids = paper.get("externalIds") or {}
            results.append({
                "id": paper.get("paperId"),
                "doi": ext_ids.get("DOI"),
                "title": paper.get("title"),
                "year": paper.get("year"),
                "abstract": paper.get("abstract"),
                "authors": [a.get("name") for a in (paper.get("authors") or [])],
                "citationCount": paper.get("citationCount"),
                "venue": paper.get("venue"),
                "source": "semantic_scholar"
            })
        return {"total": data.get("total"), "data": results}
    except requests.exceptions.Timeout:
        return {"error": "Request timed out", "data": []}
    except Exception as e:
        return {"error": str(e), "data": []}

def search_oa(query, limit=10):
    url = f"{OA_BASE_URL}/works"
    params = {
        "search": query,
        "per_page": limit,
        "select": "id,doi,title,display_name,publication_year,cited_by_count,authorships,abstract_inverted_index,primary_location"
    }
    try:
        response = requests.get(url, params=get_oa_params(params), headers=get_headers(), timeout=REQUEST_TIMEOUT)
        if response.status_code != 200:
            return {"error": f"HTTP {response.status_code}: {response.text[:200]}", "data": []}
        data = response.json()
        results = []
        for work in data.get("results", []):
            abstract = ""
            index = work.get("abstract_inverted_index")
            if index:
                words = {}
                for word, positions in index.items():
                    for pos in positions: words[pos] = word
                abstract = " ".join([words[p] for p in sorted(words.keys())])
            
            # Safe venue extraction
            venue = None
            primary_loc = work.get("primary_location")
            if primary_loc and isinstance(primary_loc, dict):
                source = primary_loc.get("source")
                if source and isinstance(source, dict):
                    venue = source.get("display_name")
            
            results.append({
                "id": work.get("id"),
                "doi": work.get("doi"),
                "title": work.get("title") or work.get("display_name"),
                "year": work.get("publication_year"),
                "citationCount": work.get("cited_by_count"),
                "authors": [a.get("author", {}).get("display_name") for a in work.get("authorships", [])],
                "abstract": abstract,
                "venue": venue,
                "source": "openalex"
            })
        return {"total": data.get("meta", {}).get("count"), "data": results}
    except requests.exceptions.Timeout:
        return {"error": "Request timed out", "data": []}
    except Exception as e:
        return {"error": str(e), "data": []}

def search_cr(query, limit=10):
    params = {"query": query, "rows": limit}
    try:
        response = requests.get(CR_BASE_URL, params=params, headers=get_headers(), timeout=REQUEST_TIMEOUT)
        if response.status_code != 200:
            return {"error": f"HTTP {response.status_code}: {response.text[:200]}", "data": []}
        data = response.json()
        items = data.get("message", {}).get("items", [])
        results = []
        for item in items:
            # Safe year extraction using helper
            year = safe_get_year(item, "published-print", "published-online", "created")
            
            results.append({
                "id": item.get("DOI"),
                "doi": item.get("DOI"),
                "title": " ".join(item.get("title", [])) if item.get("title") else None,
                "year": year,
                "authors": [f"{a.get('given', '')} {a.get('family', '')}".strip() for a in item.get("author", [])],
                "venue": " ".join(item.get("container-title", [])) if item.get("container-title") else None,
                "source": "crossref"
            })
        return {"total": data.get("message", {}).get("total-results"), "data": results}
    except requests.exceptions.Timeout:
        return {"error": "Request timed out", "data": []}
    except Exception as e:
        return {"error": str(e), "data": []}

def search_pm(query, limit=10):
    """Search PubMed using esearch + efetch for full abstracts and DOIs."""
    try:
        # Step 1: esearch to get PMIDs
        search_url = f"{PM_BASE_URL}/esearch.fcgi"
        search_params = {"db": "pubmed", "term": query, "retmax": limit, "retmode": "json"}
        search_res = requests.get(search_url, params=search_params, timeout=REQUEST_TIMEOUT)
        if search_res.status_code != 200:
            return {"error": f"esearch HTTP {search_res.status_code}", "data": []}
        ids = search_res.json().get("esearchresult", {}).get("idlist", [])
        if not ids:
            return {"total": 0, "data": []}
        
        # Step 2: efetch to get full records with abstracts
        fetch_url = f"{PM_BASE_URL}/efetch.fcgi"
        fetch_params = {
            "db": "pubmed",
            "id": ",".join(ids),
            "retmode": "xml",
            "rettype": "abstract"
        }
        fetch_res = requests.get(fetch_url, params=fetch_params, timeout=REQUEST_TIMEOUT)
        if fetch_res.status_code != 200:
            return {"error": f"efetch HTTP {fetch_res.status_code}", "data": []}
        
        # Parse XML
        results = []
        root = ET.fromstring(fetch_res.content)
        
        for article in root.findall(".//PubmedArticle"):
            pmid = None
            pmid_elem = article.find(".//PMID")
            if pmid_elem is not None:
                pmid = pmid_elem.text
            
            # Title
            title = None
            title_elem = article.find(".//ArticleTitle")
            if title_elem is not None:
                title = "".join(title_elem.itertext())
            
            # Abstract - combine all AbstractText elements
            abstract_parts = []
            for abs_elem in article.findall(".//AbstractText"):
                label = abs_elem.get("Label", "")
                text = "".join(abs_elem.itertext())
                if label:
                    abstract_parts.append(f"{label}: {text}")
                else:
                    abstract_parts.append(text)
            abstract = " ".join(abstract_parts) if abstract_parts else None
            
            # Year
            year = None
            pub_date = article.find(".//PubDate/Year")
            if pub_date is not None and pub_date.text:
                try:
                    year = int(pub_date.text)
                except ValueError:
                    pass
            if not year:
                # Try MedlineDate
                medline_date = article.find(".//PubDate/MedlineDate")
                if medline_date is not None and medline_date.text:
                    parts = medline_date.text.split()
                    if parts and parts[0].isdigit():
                        year = int(parts[0])
            
            # Authors
            authors = []
            for author in article.findall(".//Author"):
                last = author.find("LastName")
                first = author.find("ForeName")
                if last is not None:
                    name = last.text or ""
                    if first is not None and first.text:
                        name = f"{first.text} {name}"
                    authors.append(name.strip())
            
            # Journal
            venue = None
            journal = article.find(".//Journal/Title")
            if journal is not None:
                venue = journal.text
            
            # DOI
            doi = None
            for aid in article.findall(".//ArticleId"):
                if aid.get("IdType") == "doi":
                    doi = aid.text
                    break
            # Also check ELocationID
            if not doi:
                for eloc in article.findall(".//ELocationID"):
                    if eloc.get("EIdType") == "doi":
                        doi = eloc.text
                        break
            
            results.append({
                "id": pmid,
                "pmid": pmid,
                "doi": doi,
                "title": title,
                "year": year,
                "authors": authors,
                "abstract": abstract,
                "venue": venue,
                "source": "pubmed"
            })
        
        return {"total": len(results), "data": results}
    except requests.exceptions.Timeout:
        return {"error": "Request timed out", "data": []}
    except ET.ParseError as e:
        return {"error": f"XML parse error: {e}", "data": []}
    except Exception as e:
        return {"error": str(e), "data": []}

def deduplicate_by_doi(all_results):
    """Deduplicate results by DOI, keeping the first occurrence (with most metadata)."""
    seen_dois = set()
    unique = []
    no_doi = []
    
    for result in all_results:
        doi = result.get("doi")
        if doi:
            # Normalize DOI (lowercase, strip https://doi.org/ prefix)
            doi_normalized = doi.lower().replace("https://doi.org/", "").replace("http://doi.org/", "")
            if doi_normalized not in seen_dois:
                seen_dois.add(doi_normalized)
                unique.append(result)
        else:
            # Keep results without DOI (can't dedupe)
            no_doi.append(result)
    
    return unique + no_doi

def main():
    parser = argparse.ArgumentParser(description="Multi-Source Academic Search Tool")
    subparsers = parser.add_subparsers(dest="command")

    # Search
    search_parser = subparsers.add_parser("search", help="Search for papers")
    search_parser.add_argument("query", help="Search query")
    search_parser.add_argument("--limit", type=int, default=10, help="Max results per source")
    search_parser.add_argument("--source", choices=["s2", "oa", "cr", "pm", "both", "all"], default="oa", 
                               help="Source: s2 (Semantic Scholar), oa (OpenAlex), cr (Crossref), pm (PubMed)")
    search_parser.add_argument("--dedupe", action="store_true", help="Deduplicate results by DOI (auto-enabled for multi-source)")

    # Details (S2)
    details_parser = subparsers.add_parser("details", help="Get paper details (S2)")
    details_parser.add_argument("id", help="Paper ID (DOI, S2ID)")

    args = parser.parse_known_args()[0]

    if args.command == "search":
        output = {}
        if args.source in ["s2", "both", "all"]: output["semantic_scholar"] = search_s2(args.query, args.limit)
        if args.source in ["oa", "both", "all"]: output["openalex"] = search_oa(args.query, args.limit)
        if args.source in ["cr", "all"]: output["crossref"] = search_cr(args.query, args.limit)
        if args.source in ["pm", "all"]: output["pubmed"] = search_pm(args.query, args.limit)
        
        # Flatten if only one source
        if len(output) == 1:
            print(json.dumps(list(output.values())[0], indent=2, ensure_ascii=False))
        else:
            # Multi-source: deduplicate by default (or if --dedupe)
            if args.dedupe or args.source in ["both", "all"]:
                all_data = []
                for src_name, src_result in output.items():
                    all_data.extend(src_result.get("data", []))
                deduped = deduplicate_by_doi(all_data)
                combined = {
                    "total_before_dedupe": sum(len(r.get("data", [])) for r in output.values()),
                    "total_after_dedupe": len(deduped),
                    "data": deduped
                }
                print(json.dumps(combined, indent=2, ensure_ascii=False))
            else:
                print(json.dumps(output, indent=2, ensure_ascii=False))

    elif args.command == "details":
        try:
            url = f"{S2_BASE_URL}/paper/{args.id}"
            params = {"fields": "title,year,abstract,authors,citationCount,venue,externalIds,tldr,referenceCount"}
            response = requests.get(url, params=params, headers=get_s2_headers(), timeout=REQUEST_TIMEOUT)
            if response.status_code != 200:
                print(json.dumps({"error": f"HTTP {response.status_code}: {response.text[:500]}"}, indent=2))
                sys.exit(1)
            print(json.dumps(response.json(), indent=2, ensure_ascii=False))
        except requests.exceptions.Timeout:
            print(json.dumps({"error": "Request timed out"}))
            sys.exit(1)
        except Exception as e:
            print(json.dumps({"error": str(e)}))
            sys.exit(1)
    else:
        parser.print_help()

if __name__ == "__main__":
    main()


path: _meta.json
content: ownerId: kn70465et53c1486ag0bqptpcn7zj3ng
slug: literature-review
version: 1.2.0
publishedAt: 1769256891795

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