{
  "markdown": "# biolit\n\n<!-- mcp-name: io.github.rachadele/biolit -->\nmcp-name: io.github.rachadele/biolit\n\nLLM-assisted biomedical literature screening and structured extraction. Accepts PubMed alert emails and mixed lists of PMIDs, DOIs, and GEO accessions in any combination. Retrieves full text from PMC, Europe PMC, bioRxiv/medRxiv, Unpaywall, and Semantic Scholar. Supports multiple LLM providers and exposes all functionality as an MCP server.\n\n## Setup\n\n**Requirements:** Python 3.8+\n\nInstall from PyPI:\n\n```bash\npip install biolit\n```\n\nOr install from source for development:\n\n```bash\npip install -e .\n```\n\nCopy `.env.example` to `.env` and add your API key:\n\n```bash\ncp .env.example .env\n# edit .env and set ANTHROPIC_API_KEY (or OPENAI_API_KEY)\n```\n\nOn macOS, you can store the key in the system keychain instead of `.env`. biolit consults the keychain by service name only (no account required):\n\n```bash\nsecurity add-generic-password -s ANTHROPIC_API_KEY -w\n# or for OpenAI:\nsecurity add-generic-password -s OPENAI_API_KEY -w\n```\n\nOmit `-w <value>` to be prompted for the key without echoing it. The keychain is checked first; the env var is used only as a fallback (so a stale value in `.env` cannot mask a working keychain entry).\n\n## Usage\n\nThe tool accepts a PubMed alert email (`.eml`) or a plain-text file of identifiers, as well as inline identifiers via `--ids`. Identifiers can be PMIDs, DOIs, or GEO accessions — mixed lists are supported in a single run.\n\n| Input | How to pass | Example |\n|---|---|---|\n| PubMed alert email | positional `.eml` file | `alert.eml` |\n| BibTeX file | positional `.bib` file | `refs.bib` |\n| Identifier file (mixed) | positional plain-text file, one per line | `identifiers.txt` |\n| Inline identifiers | `--ids` flag, comma-separated | `--ids 41795042,GSE53987,10.1101/2025.03.17.25324098` |\n\nUse `--default` to run with schizophrenia genomics defaults (no prompts):\n\n```bash\nbiolit docs/alert.eml --default\nbiolit docs/pmids.txt --default\nbiolit docs/geo_accessions.txt --default\nbiolit --ids 41795042,41792186,GSE53987 --default\nbiolit --ids 10.1101/2025.03.17.25324098 --default\n```\n\nOr specify criterion and fields as flags:\n\n```bash\nbiolit identifiers.txt \\\n  --criterion \"Is this about treatment-resistant schizophrenia?\" \\\n  --fields \"methodology, sample_size, treatment, outcomes\"\n```\n\nAdd `--markdown` (or `--md`) to also write a prose `.md` summary alongside the CSV. Each record gets a markdown section with `### field` subsections; records that failed or were skipped appear as stub entries:\n\n```bash\nbiolit refs.bib --config my_config.json --markdown\nbiolit refs.bib --config my_config.json --markdown --markdown-max-tokens 2048\n```\n\nAdd `--batch` to issue screening, extraction, and markdown rendering through the provider's Message Batches / Batch API instead of one call per record. Per-request cost drops by ~50%, but each batch blocks on completion (typically several minutes per stage; up to 6 hours), so it's intended for the bulk weekly-alert case rather than one-off lookups. Anthropic and OpenAI only — falls back to sequential calls on Ollama or on OpenAI-compatible endpoints with a custom `base_url`. Also accepted as `\"batch\": true` in a config file.\n\n```bash\nbiolit docs/alert.eml --default --batch\nbiolit docs/alert.eml --default --batch --markdown   # batches markdown too\n```\n\nOr use a JSON config file to store reusable parameters (CLI flags take precedence). The config can include `ids` or `input_file` (path to an `.eml`, `.bib`, or identifier list), and `\"markdown\": true` to enable markdown output:\n\n```bash\nbiolit alert.eml --config my_config.json\nbiolit refs.bib --config my_config.json   # DOIs extracted from .bib automatically\nbiolit --config my_config.json            # ids or input_file supplied by config\n```\n\nThe `fields` key in a config file can be a comma-separated string or a JSON object mapping field names to extraction descriptions. When a string is used, an extra LLM call converts the field names into descriptions before extraction. When a dict is used, that call is skipped — the descriptions are passed directly to the model:\n\n```json\n{\n  \"fields\": {\n    \"tf_name\": \"HGNC symbol of the transcription factor perturbed in this experiment\",\n    \"organism\": \"scientific name of the organism used\",\n    \"platform\": \"GPL accession of the microarray platform\"\n  }\n}\n```\n\nOmit `--criterion` to skip screening (all records are extracted). Omit `--fields` to use the default fields (`methodology, sample_type, causal_claims, summary`):\n\n```bash\n# fetch + extract with defaults (no screening)\nbiolit alert.eml\n\n# fetch + screen only, then extract with defaults\nbiolit alert.eml --criterion \"Is this about treatment-resistant schizophrenia?\"\n```\n\n### Single-record screening\n\nUse `biolit screen` to quickly check one paper or GEO record for relevance without running the full extraction pipeline:\n\n```bash\nbiolit screen --pmid 41627908 --default\nbiolit screen --accession GSE53987 --default\nbiolit screen --doi 10.64898/2026.02.16.706214 --default\nbiolit screen --pmid 41627908 --criterion \"Is this about treatment-resistant schizophrenia?\"\n```\n\nOutput is a single line to stdout:\n\n```\nRELEVANT [abstract] — Paper uses GWAS to investigate schizophrenia risk loci.\n```\n\n### Mixed identifier lists\n\nPMIDs, DOIs, and GEO accessions can be freely mixed in a file or via `--ids`. Each identifier is auto-detected by format:\n\n- `41795042` → PMID (all digits)\n- `10.1101/2025.03.17.25324098` → DOI (starts with `10.`)\n- `GSE53987` → GEO accession (starts with `GSE`, `GDS`, `GSM`, or `GPL`)\n\n```bash\nbiolit --ids 41795042,GSE53987,10.1101/2025.03.17.25324098 --default\n```\n\nGEO records additionally include a `linked_pmids` column. All record types share `pmid`, `doi`, and `geo_accession` columns (null when not applicable).\n\n### Full-text retrieval\n\nFull-text retrieval runs automatically for every PMID and DOI (including preprints). For GEO records, the pipeline attempts full-text retrieval via each linked PMID in order, falling back to the GEO record metadata if no linked paper has accessible full text. The pipeline tries each source in order:\n\n1. PMC JATS XML (open access)\n2. Europe PMC JATS XML (broader open-access coverage)\n3. Preprint XML (bioRxiv / medRxiv)\n4. Unpaywall PDF (requires `--unpaywall-email`)\n5. Semantic Scholar open-access PDF\n6. OpenAlex green-OA PDF (author manuscripts Unpaywall/S2 miss; key-less)\n7. Europe PMC open-access full-text PDF (OA subset)\n8. CORE aggregated green-OA PDF (opt-in; needs `CORE_API_KEY`)\n9. Publisher landing-page scrape (the `citation_pdf_url` meta tag; key-less)\n10. Custom resolvers (institutional OpenURL / library proxy; opt-in via `BIOLIT_CUSTOM_RESOLVERS`)\n11. Publisher landing-page **HTML** full text (the `citation_fulltext_html_url` meta tag; key-less)\n12. Abstract fallback\n\nSteps 6-9 are all open-access-only (green-OA author manuscripts,\ninstitutional-repository copies, and the publisher's own advertised OA\nPDF link) — never a paywall bypass. OpenAlex, Europe PMC, and the\nlanding-page scrape need no key; CORE is a no-op unless `CORE_API_KEY` is\nset. The landing-page scrape (step 9) follows the DOI to the article page\nand reads the `citation_pdf_url` link the publisher itself embeds (the\nHighwire / Google-Scholar standard) — this catches OA PDFs the aggregator\nAPIs mislabel or never index. bioRxiv / medRxiv are skipped there (their\nservers block agents; the preprint step above covers them). Step 10 is\nthe seam for *your own authorized* access — see\n[Custom full-text fetchers](#custom-full-text-fetchers). Step 11 is the\nHTML counterpart of step 9: when no downloadable PDF exists at all, it\nextracts the **article body text** from the publisher's full HTML page\n(the `citation_fulltext_html_url` Highwire signal that PLOS / eLife / BMC\n/ Frontiers and many society journals set) — recovering Methods text for\nOA papers the PDF chain can never reach. When even that misses, the\nabstract fallback records a `paper_status` classification (`bot_blocked`\n/ `js_shell` / `abstract`) in the per-record artifacts so a caller can\nread *why* full text was not reached.\n\nTo enable Unpaywall (step 4), pass your email:\n\n```bash\nbiolit alert.eml --default --unpaywall-email you@example.com\n```\n\nLimit which sections are sent to the LLM:\n\n```bash\nbiolit alert.eml --default --sections methods,results\n```\n\n### LLM providers\n\nThe tool supports Anthropic (default), OpenAI, and local Ollama models:\n\n```bash\n# OpenAI\nbiolit pmids.txt --default --provider openai --model gpt-4o\n\n# Ollama (local)\nbiolit pmids.txt --default --provider ollama --model llama3\n```\n\nYou can also set `LLM_PROVIDER` and `LLM_MODEL` as environment variables.\n\n## Output\n\nEach run creates a timestamped directory (e.g. `run_20260313_142000/`) containing:\n\n- `results.csv` — one row per relevant record\n- `results.md` — prose markdown summary (written when `--markdown` or `\"markdown\": true` in config)\n- `artifacts/<id>/` — per-record folder with the text sent to the LLM, metadata, and any retrieved full-text files\n\nRecords that fail at any pipeline stage (fetch error, not found, no content, screening or extraction error) are excluded from the CSV but appear in the markdown as stub entries with a failure note.\n\nWith default fields, the CSV columns are:\n\n| Column | Description |\n|---|---|\n| `title` | Paper title |\n| `authors` | Author list (comma-separated; parsed from PubMed XML, bioRxiv/medRxiv API, or GEO contributors) |\n| `url` | Link to PubMed, GEO, or DOI |\n| `pmid` | PubMed ID (null for unindexed preprints) |\n| `doi` | DOI (null for GEO records) |\n| `geo_accession` | GEO accession (null for non-GEO records) |\n| `text_source` | Where the text came from (`abstract`, `pmc_fulltext`, `europepmc_fulltext`, `preprint_fulltext`, `unpaywall_pdf`, `s2_pdf`, `openalex_pdf`, `europepmc_oa_pdf`, `core_pdf`, `landing_page_pdf`, `custom_resolver_pdf`, `landing_page_html`, `geo_linked_fulltext`, `geo_linked_abstract`, `geo_record`) |\n| `citation_count` | Citation count from Semantic Scholar (null if not found) |\n| `methodology` | General method (e.g. GWAS, scRNA-seq, proteomics) |\n| `sample_type` | Tissue/sample type and origin |\n| `causal_claims` | Statements about causes of schizophrenia inferred from the data |\n| `summary` | 2-3 sentence plain-language summary for triage |\n\nGEO records additionally include a `linked_pmids` column listing all associated PubMed IDs.\n\nThe CSV can be imported directly into Google Sheets (File → Import).\n\n## MCP server\n\n`biolit` ships an MCP server that exposes the pipeline as tools for any MCP-compatible client (Claude Desktop, Claude CLI, OpenAI Agents SDK, etc.).\n\nStart the server:\n\n```bash\nbiolit-mcp\n# or pick a provider/model explicitly (overrides LLM_PROVIDER / LLM_MODEL env vars):\nbiolit-mcp --provider openai --model gpt-4o-mini\n```\n\nOr test interactively with the MCP inspector:\n\n```bash\nmcp dev biolit/mcp_server.py\n```\n\n### Configure Claude Desktop\n\nAdd to `~/Library/Application Support/Claude/claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"biolit\": {\n      \"command\": \"biolit-mcp\",\n      \"args\": [\"--provider\", \"openai\"]\n    }\n  }\n}\n```\n\nRestart Claude Desktop. The tools will appear in the tool picker. Drop `args` to use the default Anthropic provider (or set `LLM_PROVIDER` / `LLM_MODEL` env vars instead).\n\n### Configure Claude CLI\n\nAdd a `.mcp.json` in your project root:\n\n```json\n{\n  \"mcpServers\": {\n    \"biolit\": {\n      \"command\": \"biolit-mcp\",\n      \"args\": [\"--provider\", \"openai\"]\n    }\n  }\n}\n```\n\n### Available tools\n\n**Batch pipeline** (equivalent to the `biolit` CLI):\n\n| Tool | Description |\n|---|---|\n| `run_pipeline` | Fetch, optionally screen, and optionally extract a mixed list of PMIDs, DOIs, and/or GEO accessions; write results CSV (and optionally a `.md` summary when `markdown=True`). Accepts `ids` (comma-separated), `bib_path` (`.bib` file), or `ids_file` (plain-text identifier file). Pass `sections` (comma-separated, e.g. `\"methods,results\"`) to restrict which full-text sections reach the LLM. Use `max_tokens` to cap input text (default 12500), `extraction_max_tokens` for field extraction output (default 4096), and `markdown_max_tokens` for markdown rendering (default 1024). Pass `0` for any token param to use the default. Pass `batch=True` to run screening, extraction, and markdown rendering through the provider's batch API (~50% cheaper, blocks until completion). All parameters optional — pass only `config_path` to drive the entire run from a JSON file. |\n\n**Low-level** (for custom workflows):\n\n| Tool | Description |\n|---|---|\n| `fetch_pubmed_metadata` | Fetch PubMed metadata by PMID |\n| `fetch_geo_record` | Fetch and parse a GEO record by accession |\n| `fetch_fulltext` | Retrieve full text for a PMID (6-step chain) |\n| `fetch_geo_fulltext` | Retrieve full text for a GEO accession via its linked PMIDs |\n| `fetch_supplementary` | Retrieve & extract text from a paper's supplementary files (supplementary methods, tables) via Europe PMC |\n| `screen_paper` | LLM relevance screen given pre-fetched text |\n| `extract_fields` | Structured field extraction given pre-fetched text |\n| `resolve_doi` | Resolve a DOI to PMID + PMCID via the NCBI ID Converter |\n| `lookup_s2_pdf` | Check whether Semantic Scholar has an open-access PDF for a DOI |\n| `read_pmids_from_eml` | Parse PMIDs from a PubMed alert `.eml` file |\n| `get_version` | Return the installed biolit package version |\n\n### Use as a Python library\n\nThe pipeline functions are importable directly:\n\n```python\nfrom biolit.pipeline import run, screen_paper, fetch_record\nfrom biolit.llm import get_llm_client\n\nclient = get_llm_client(\"anthropic\")\n\n# Batch pipeline — PMIDs, DOIs, and GEO accessions can be mixed freely\n# criterion and fields_description are optional; omit either to skip that step\n# markdown=True writes results.md alongside the CSV\n# Returns (csv_path, record_count)\ncsv_path, count = run(client, ids=[\"41627908\", \"GSE53987\", \"10.1101/2025.03.17.25324098\"],\n    criterion=\"...\", fields_description=\"methodology, summary\", output_path=\"results.csv\",\n    markdown=True)\n\n# Fetch + write metadata only (no LLM calls)\ncsv_path, count = run(client, ids=[\"41627908\", \"GSE53987\"])\n\n# Fetch a single record (auto-detects PMID / DOI / GEO)\npaper = fetch_record(\"10.1101/2025.03.17.25324098\")\n\n# Screen pre-fetched text\nresult = screen_paper(client, paper, \"Is this about schizophrenia genomics?\", paper[\"abstract\"])\n# {\"relevant\": True, \"reason\": \"...\"}\n```\n\n## Custom full-text fetchers\n\nThe built-in chain (PMC → Europe PMC → preprint → Unpaywall → Semantic\nScholar → OpenAlex → Europe PMC OA PDF → CORE → landing-page PDF scrape →\ncustom resolvers → landing-page HTML → abstract) leaves coverage gaps for\nclosed-access or recently-published work. You can plug in additional sources of full text\n— a Zotero library, a flat directory of PDFs, an institutional\nfull-text database — without forking biolit.\n\n### Landing-page scrape and custom resolvers (built-in, opt-in via env vars)\n\nTwo built-in chain steps mirror Zotero's \"Find Full Text\" file\nresolvers and are configured the same way as Unpaywall / CORE — through\nenvironment variables, no code changes.\n\n**Landing-page scrape (step 9).** Always on, key-less. Resolves the DOI\nto the publisher's article page and reads the PDF link the page itself\nadvertises — `<meta name=\"citation_pdf_url\">` first (the Highwire /\nGoogle-Scholar standard most publishers embed), then `<link\nrel=\"alternate\" type=\"application/pdf\">`, Open Graph / Twitter-card PDF\npointers, and obvious `*.pdf` anchors. A `<meta http-equiv=\"refresh\">`\nredirect is followed once; every candidate is verified to start with the\n`%PDF` magic. This follows the publisher's *own advertised* OA link — not\na paywall bypass — and catches OA PDFs the aggregator APIs mislabel or\nnever index. bioRxiv / medRxiv are skipped (their servers block agents;\nthe preprint step covers them). Source label: `landing_page_pdf`.\n\n```bash\n# Optional: override the browser User-Agent used for the landing-page\n# request (some publishers serve a stub or 403 to non-browser agents).\nexport BIOLIT_LANDING_USER_AGENT=\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36\"\n```\n\n**Landing-page HTML full text (step 11).** Always on, key-less. The HTML\ncounterpart of step 9. When no downloadable PDF was found anywhere, this\nfollows the DOI to the article page, reads the publisher's\n`<meta name=\"citation_fulltext_html_url\">` \"a full HTML version exists\"\nsignal (set by PLOS / eLife / BMC / Frontiers and many society journals),\nand extracts the **article body text** — dropping nav / scripts / styles /\nchrome and preferring an `<article>` / `<main>` container. This recovers\nMethods text for OA papers that have no PDF at all. Bot-challenge pages\n(Cloudflare \"Just a moment\") and JavaScript-rendered shells are rejected\nrather than returned as \"text\". bioRxiv / medRxiv are skipped (the preprint\nstep covers them). Source label: `landing_page_html`. It shares the\n`BIOLIT_LANDING_USER_AGENT` override above.\n\nWhen the whole chain misses, the abstract fallback classifies *why* full\ntext was not reached (`bot_blocked` / `js_shell` / `abstract`) and records\nit as `paper_status` in the per-record artifacts, so a downstream caller\ncan distinguish \"blocked by a bot challenge\" from \"publisher serves only a\nJS shell\" from \"genuinely abstract-only\".\n\n```bash\n# Optional: visible-character floor below which a fetched landing page is\n# treated as a JS-rendered shell (no server-rendered article body) rather\n# than full text. Default 2000.\nexport BIOLIT_JS_SHELL_CHAR_THRESHOLD=2000\n```\n\n**Custom resolvers (step 10).** Opt-in. This is the seam for *your own\nauthorized* access — typically your institution's OpenURL endpoint or a\nlibrary EZproxy URL pattern. Set `BIOLIT_CUSTOM_RESOLVERS` to a JSON\narray of resolver entries. Each entry needs a `url_template` with\n`{doi}` / `{url}` placeholders; the URL is fetched with your configured\nheaders and returned if it is a real PDF. When the resolved URL is an\nOpenURL / proxy *landing page* rather than a direct PDF, set\n`\"scrape\": true` to run its HTML through the landing-page scraper above.\nWith nothing configured this step is a no-op. Source label:\n`custom_resolver_pdf`.\n\n```bash\nexport BIOLIT_CUSTOM_RESOLVERS='[\n  {\n    \"url_template\": \"https://proxy.lib.example.edu/login?url=https://doi.org/{doi}\",\n    \"headers\": {\"Cookie\": \"ezproxy=YOUR_SESSION\"},\n    \"scrape\": true\n  },\n  {\n    \"url_template\": \"https://resolver.example.edu/openurl?id=doi:{doi_encoded}&svc=fulltext\"\n  }\n]'\n```\n\nResolver-entry schema (only `url_template` is required):\n\n| Key | Meaning |\n|---|---|\n| `url_template` | URL with placeholders `{doi}`, `{doi_encoded}` (URL-quoted), `{url}`, `{url_encoded}`. An entry is skipped if its template needs a value that is unavailable. |\n| `user_agent` | Optional per-entry User-Agent override. |\n| `headers` | Optional dict of extra request headers (e.g. a proxy session `Cookie`). |\n| `scrape` | Optional bool — when true and the resolved URL returns HTML, scrape it for a PDF link via the landing-page scraper. |\n\n> biolit never hardcodes, requests, or stores credentials. Custom\n> resolvers follow *your* configured URL patterns using *your own\n> authorized* access (proxy login, session cookies, etc.). You are\n> responsible for ensuring your use complies with your institution's and\n> the publisher's terms of service.\n\n### Reference fetchers (opt-in via env vars)\n\nThree ship with biolit and self-register on import when the relevant\nenvironment variables are set. Default priorities (lower = tried\nearlier) are `bibtex=2.0`, `local_pdf=3.0`, `zotero=5.0`.\n\n**BibTeX.** Looks up papers by DOI, PMID, or citekey in a `.bib`\nexport, reads the path from each entry's `file = {...}` field, and\nparses the PDF directly. Best fit for users who maintain a\nBetter-BibTeX (or equivalent) auto-export — lookups are offline,\nin-memory, and exact, with no network round-trip and no dependence on\nthe Zotero search index. Works around the Zotero web API's q-search\nnot indexing the structured `DOI` field, which makes DOI lookups via\nthat API unreliable for items where the DOI doesn't appear in indexed\nattachment full-text. Supports both BBT semicolon-separated `file`\nlists and the classic JabRef `description:path:type` format. The bib\nfile is re-parsed automatically when its mtime changes.\n\n```bash\nexport BIOLIT_BIBTEX=~/Zotero/My\\ Library.bib\n# Optional:\nexport BIOLIT_BIBTEX_PRIORITY=2.0   # lower = tried earlier (default 2.0)\n```\n\n**Zotero.** Searches the user's Zotero library by DOI then PMID,\nresolves attachment search hits up to their parent items, finds an\nattached PDF, downloads it, and parses it with biolit's PDF parser.\nWhen the Zotero `/file` API endpoint returns 404 (linked_file\nattachments, or imported attachments on accounts without sync), falls\nback to reading the PDF from local Zotero storage at\n`$ZOTERO_DATA_DIR/storage/<key>/<filename>` (default data dir\n`~/Zotero`). Note: Zotero's web API q-search does not index the\nstructured DOI field, so the BibTeX fetcher above is more reliable\nwhen both are available.\n\n```bash\nexport ZOTERO_API_KEY=...\nexport ZOTERO_USER_ID=...           # or ZOTERO_GROUP_ID for a group library\n# Optional:\nexport ZOTERO_PRIORITY=5.0          # lower = tried earlier (default 5.0)\nexport ZOTERO_DATA_DIR=~/Zotero     # only needed if Zotero is not at ~/Zotero\n```\n\nOn macOS, any of `ZOTERO_API_KEY`, `ZOTERO_USER_ID`, and `ZOTERO_GROUP_ID`\nthat are not in the environment fall back to the macOS keychain\n(`security find-generic-password -s <NAME> -w`), matching the resolution\norder used for LLM API keys. This means hosts like Claude Code that\ndon't shell-source your profile can still pick up Zotero credentials\nwithout an `env` block in `.mcp.json`.\n\n**Local PDF directory.** Looks up papers by DOI in a pre-built JSON\nindex. Filenames are arbitrary — DOIs are extracted from each PDF's\n`/Info` metadata dict and (failing that) its first-page text.\n\nBuild (or update) the index. Re-running is cheap — by default only\nnew or changed PDFs are re-extracted:\n\n```bash\npython -m biolit.fetchers.local_pdf --dir ~/Papers\npython -m biolit.fetchers.local_pdf --dir ~/Papers --rebuild   # force full re-extraction\n```\n\nThen point biolit at the same directory:\n\n```bash\nexport BIOLIT_LOCAL_PDF_DIR=~/Papers\nexport BIOLIT_LOCAL_PDF_PRIORITY=3.0  # default 3.0\n```\n\nThe fetcher itself never builds the index — it only consults it. PDFs\nwithout an extractable DOI are listed in the index's\n`unindexed_sample` for visibility.\n\nWhen configured, the `text_source` field in CSV/markdown output is\n`bibtex_pdf`, `zotero_pdf`, or `local_pdf` for hits from these\nsources. The raw bytes are persisted into `artifacts/<id>/bibtex_pdf`\n/ `zotero_pdf` / `local_pdf` exactly like the built-in PMC/Europe PMC\nartifacts.\n\n### Writing your own fetcher\n\nA fetcher is any callable that takes a `FetchContext` and returns either\na `FetchResult` (when it found something) or `None` (when it didn't).\n\n```python\nfrom biolit.fetchers import FetchContext, FetchResult, register_fetcher\n\ndef my_internal_db_fetcher(ctx: FetchContext) -> FetchResult | None:\n    pmid = ctx.paper.get(\"pmid\")\n    if not pmid:\n        return None\n    text = my_db.lookup_fulltext(pmid)  # whatever you have\n    if not text:\n        return None\n    return FetchResult(text=text, source=\"internal_db\", artifacts={})\n\nregister_fetcher(my_internal_db_fetcher, priority=1.0, name=\"internal_db\")\n```\n\nRegister before the first call to `run` / `screen_by_*` (e.g. at module\nimport time). Registered fetchers are tried before the built-in chain in\npriority order; the first one to return a non-empty `FetchResult.text`\nwins. Exceptions inside a fetcher are logged to stderr and the next\nfetcher is tried.\n\n## Validation\n\nAn independent evaluation of the GEO screening and metadata extraction workflow is available at [rachadele/biolit-eval](https://github.com/rachadele/biolit-eval). It uses a bootstrap resampling pipeline to estimate precision, recall, and F1 against a manually curated ground truth of 509 GEO accessions labelled for transcription factor perturbation experiments.\n\n## Known Limitations\n\n- Papers without abstracts or accessible full text are skipped silently.\n- GEO records attempt full-text retrieval via linked PMIDs. `text_source` will be `geo_linked_fulltext`, `geo_linked_abstract`, or `geo_record` depending on what was accessible.\n- bioRxiv/medRxiv JATS XML is frequently blocked by Cloudflare regardless of headers. The pipeline falls back to the title and abstract from the bioRxiv API (`text_source: preprint_abstract`).\n- The Semantic Scholar API allows roughly 100 unauthenticated requests per day. Set `SEMANTIC_SCHOLAR_API_KEY` in `.env` for higher limits.\n",
  "bytes": 24833,
  "sha": "11e046af27bf2b816e631cad885ce68ee21a9899642d3e973de5f0e2c936a907",
  "repo_slug": "rachadele/biolit",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_rachadele_biolit_a745b4cb/readme"
}