---
name: baixar-papers
description: 'Baixa PDFs acadêmicos em massa a partir de DOIs via cadeia de 16 estratégias (OpenAlex, arXiv, OSF, Zenodo, Crossref, Unpaywall, EuropePMC, PMC, OpenAIRE, CORE, HAL, IA Scholar, Scholar repos, session cookies, Sci-Hub). Extrai PDF→Markdown com pymupdf4llm, gera sidecars .md com YAML frontmatter NotebookLM-ready e empacota em ZIP. Fallback manual via Semantic Scholar, Unpaywall, CORE, OpenAIRE Graph API, ZORA DSpace API e Wayback Machine. Dispara com "baixa esses papers", "pega os PDFs", "monta ZIP pro NotebookLM", ou ao colar lista de DOIs. NÃO usar para busca (use pesquisa-academica) nem verificação individual (use verify_citations.py). ATENCAO: CORE sem CORE_API_KEY retorna erros (~67%) — pular tier 11. OpenAIRE SearchAPI DESLIGOU em 31/Mai/2026 — usar apenas Graph API. Sci-Hub bloqueado por DDoS-Guard na sandbox Cowork (tier 16 inoperante). ResearchGate direct PDF retorna error 1020 (Cloudflare) — não usar.'
---

# baixar-papers v2.3.2

## ⚙️ BOOTSTRAP OBRIGATÓRIO — EXECUTE ANTES DE QUALQUER OUTRA COISA

A cada sessão nova, os scripts precisam ser materializados em /tmp/baixar-papers/.
Execute este bloco como primeiro passo:

```bash
python3 -c "
import sys, zipfile
from pathlib import Path
DEST = Path('/tmp/baixar-papers')
if (DEST / 'scripts' / 'orchestrator.py').exists():
    print('[OK] scripts já presentes'); sys.exit(0)
zips = []
for base in ('cowork-assets', 'uploads'):
    zips += list(Path('/sessions').glob(f'**/mnt/{base}/baixar-papers-v2_3_1.zip'))
if not zips:  # fallback genérico: pode pegar versão velha, avisar
    for base in ('cowork-assets', 'uploads'):
        zips += list(Path('/sessions').glob(f'**/mnt/{base}/baixar-papers*.zip'))
    if zips: print('[AVISO] v2_3_1 não achado; usando', zips[0].name, '— versão pode estar defasada')
if not zips:
    print('[ERRO] Envie o arquivo baixar-papers-v2_3_1.zip na conversa'); sys.exit(1)
with zipfile.ZipFile(zips[0]) as zf: zf.extractall('/tmp')
extracted = Path('/tmp/baixar-papers')
print('[OK] scripts prontos em', extracted)
"
```

Se retornar ERRO, peça ao usuário para enviar `baixar-papers-v2_3_1.zip` na conversa.
Após bootstrap OK, use `/tmp/baixar-papers/scripts/` como base para todos os caminhos.

**Cópia canônica do zip:** `baixar-papers-v2_3_1.zip`, gerado em 10/06/2026.
Mora na área de trabalho do autor (10/06/2026). Em sessão Cowork onde o
bootstrap falhar, reanexar de lá. Quando uma versão nova sair, sobrescrever
a cópia da área de trabalho e atualizar esta data.

---

Skill pra download em massa de PDFs acadêmicos com extração Markdown NotebookLM-ready.

## Quick start

```bash
# Salva DOIs em arquivo (1 por linha)
cat > dois.txt <<EOF
10.1002/cpp.2328
10.1037/pst0000317
10.31234/osf.io/abc123
EOF

# Pipeline completo: download → extract → package
export OPENALEX_API_KEY="sua_chave"   # obrigatória desde 13/Fev/2026 (openalex.org/settings/api)
python3 scripts/batch.py --doi-file dois.txt --output-dir out/ --email "seu@email.com"
# Nota: --email segue aceito pelos outros conectores (Unpaywall, Crossref);
# no OpenAlex o mailto foi aposentado e a chave é o que vale.
python3 scripts/package_bundle.py --batch-dir out/ --output papers.zip
```

> ⚠️ **Timeout de shell (45s):** `batch.py` e `package_bundle.py` são longos. Rodar em background (`nohup ... &`) ou processar em lotes menores. Se o batch for interrompido, o `_summary.json` **não é gerado** — e `package_bundle.py` vai falhar com `_summary.json not found`. Nesse caso, usar o protocolo de empacotamento manual na seção "Fallback manual via MCPs".

> ⚠️ **OpenAIRE (tier 10):** A SearchAPI legada **DESLIGOU em 31/Mai/2026**. O conector `openaire` no batch falha silenciosamente a partir dessa data. Verificar com `python3 scripts/cache.py --stats` se DOIs acumulam `FAIL_TRANSIENT` com fonte `openaire`. Usar apenas a Graph API no fallback manual (ver Passo 4).

> 🚨 **OpenAlex (tier 1) — API NOVA desde 13/Fev/2026:** chave de API agora é **obrigatória** em toda chamada. Sem chave: ~100 créditos/dia (só teste), depois erro 409. O parâmetro `mailto` e o "polite pool" foram **aposentados** — remover dos conectores, não funcionam mais. Chave gratuita em openalex.org/settings/api (100.000 créditos/dia, ~$1/dia de uso). Custos por tipo: lookup singleton por DOI é barato/grátis, listas custam mais, **download de conteúdo (PDF/TEI, ~60M obras OA) custa 100 créditos por arquivo** e existe como endpoint novo, além de CLI oficial (`openalex download --content pdf`). Status da cota via `GET api.openalex.org/rate-limit?api_key=KEY` ou headers `X-RateLimit-*`. Exportar `OPENALEX_API_KEY` antes de rodar o batch; sem ela, o tier 1 e o batch_enrich degradam pra cota de teste e o pipeline inteiro fica mais lento e falho. Confirmado em docs.openalex.org e anúncio oficial (acesso 10/06/2026).

> ⚠️ **Sci-Hub (tier 16):** Bloqueado por DDoS-Guard no ambiente sandbox Cowork — retorna HTML de bloqueio, não PDF. O tier 16 é inoperante nesse ambiente. Não desperdiçar tentativas.

> ⚠️ **ResearchGate direct PDFs:** Links do padrão `researchgate.net/.../links/HASH/FileName.pdf` retornam `error code: 1020` (Cloudflare bot protection) independente de User-Agent. Não tentável via curl ou urllib na sandbox.

## Pipeline

```
DOIs → batch.py (orquestra concorrente)
         ├→ orchestrator.py por DOI (16-step chain)
         │    └→ connectors/* (openalex, arxiv, osf, ...)
         ├→ cache.py (6-state SQLite com TTL)
         └→ batch_enrich (OpenAlex 100 DOIs/req)
       → package_bundle.py
            ├→ extract_pdf.py (pymupdf4llm → marker fallback)
            ├→ markdown_emit.py (sidecar com YAML frontmatter)
            └→ ZIP com manifest.{json,md} + _failed.json
```

## 16-step fallback chain

| # | Connector | Cobre | Status legal |
|---|-----------|-------|--------------|
| 1 | openalex | DOIs com PDF hospedado pelo OpenAlex (~60M OA works). **Requer OPENALEX_API_KEY desde 13/Fev/2026; mailto aposentado; download de conteúdo = 100 créditos/arquivo** | ✅ legal |
| 2 | arxiv | DOIs `10.48550/arXiv.*` | ✅ legal |
| 3 | osf | PsyArXiv (10.31234), SocArXiv (10.31235), MetaArXiv | ✅ legal |
| 4 | zenodo | DOIs `10.5281/zenodo.*` | ✅ legal |
| 5 | crossref_publisher | Publisher OA com license CC/TDM allowlist | ✅ legal |
| 6 | unpaywall | Best + all oa_locations | ✅ legal |
| 7 | europepmc | Biomédica via PMCID search | ✅ legal |
| 8 | pmc_direct | NCBI idconv → efetch | ✅ legal |
| 9 | crossref_tdm | text-mining intended links | ✅ legal |
| 10 | openaire | Graph API apenas — SearchAPI legada **DESLIGOU 31/Mai/2026** | ✅ legal |
| 11 | core | CORE API v3 (requer CORE_API_KEY no script; busca pública `/v3/search/works?q=doi:X` funciona sem key no fallback manual) | ✅ legal |
| 12 | hal | French archives ouvertes | ✅ legal |
| 13 | ia_scholar | scholar.archive.org | ✅ legal |
| 14 | scholar_repo | Google Scholar repos institucionais (rate-limited) | ✅ legal |
| 15 | session_cookies | T&F/Cambridge/Springer/Karger via cookies | ⚠ TOS-dependente |
| 16 | scihub | sci.bban.top + sci-hub.ee | ⚠ shadow library |

## Flags

| Flag | Default | Efeito |
|------|---------|--------|
| `--no-shadow` | OFF | Desabilita Sci-Hub (tier 16) |
| `--license-bypass` | OFF | Aceita qualquer license Crossref |
| `--workers N` | 8 | Concurrent downloads no batch |
| `--quality fast/auto/high` | auto | Tier de extração PDF→MD |

## Entry points

- `scripts/orchestrator.py` — single DOI download
- `scripts/batch.py` — batch concorrente (preferido)
- `scripts/extract_pdf.py` — PDF→Markdown standalone
- `scripts/extract_text.py` — texto fallback (Europe PMC XML / SciELO)
- `scripts/citation_traverse.py` — vizinhos OA pra DOIs falhos
- `scripts/package_bundle.py` — empacota tudo em ZIP NotebookLM-ready
- `scripts/cache.py --stats` — diagnóstico do cache

## Capability matrix

| Source | Search | Download | Read full text | License-aware |
|--------|--------|----------|----------------|---------------|
| openalex | indireto via DOI | ✅ direto + locations | parcial (abstract invertido) | ✅ |
| arxiv | ❌ aqui | ✅ | ❌ | perpetual |
| osf | parcial | ✅ via primary_file | ❌ | ✅ |
| zenodo | ❌ aqui | ✅ via REST API | ❌ | ✅ |
| crossref | ✅ | ✅ via link[] | ❌ | ✅ allowlist |
| unpaywall | ❌ | ✅ best+all locs | ❌ | ✅ |
| europepmc | ✅ | ✅ via PMCID | ✅ JATS XML | ✅ |
| pmc_direct | ✅ via idconv | ✅ | ✅ JATS XML | ✅ |
| openaire | ✅ | ✅ | ❌ | ✅ |
| core | ✅ | ✅ | parcial | ✅ |
| hal | ❌ | ✅ via fileMain_s | ❌ | ✅ |
| ia_scholar | ✅ | ✅ archived | ❌ | ✅ |
| scholar_repo | ✅ | ✅ rate-limited | ❌ | ❌ |
| session_cookies | ❌ | parcial | ❌ | ❌ |
| scihub | ❌ | ✅ alta cobertura | ❌ | ⚠ shadow |

## Stat anchors NotebookLM

A função `wrap_statistical_anchors` em `extract_pdf.py` envolve em inline-code:

- `N = 1,243`
- `p < .001`
- `β = -0.42`
- `R² = 0.87`
- `95% CI [-0.61, -0.23]`
- `d = 0.5`
- `OR = 1.5`
- `r = 0.42`
- `F(2,123) = 4.5`
- `t(123) = 2.5`

NotebookLM raramente parafraseia inside `...` → preserva números na sumarização.

## Variáveis de ambiente

```bash
export OPENALEX_EMAIL="seu@email.com"      # opcional, pra polite pool
export UNPAYWALL_EMAIL="seu@email.com"     # obrigatório pra Unpaywall
export CROSSREF_EMAIL="seu@email.com"      # opcional, pra polite pool
export NCBI_EMAIL="seu@email.com"          # opcional, NCBI tools
export CORE_API_KEY="..."                   # pra tier 11 (CORE)
export OPENALEX_API_KEY="..."               # opcional, eleva rate-limit
export CROSSREF_PLUS_TOKEN="..."            # opcional, TDM premium
```

## Cache

SQLite em `cache/attempts.sqlite`. Schema 6-state com TTL:

| Status | TTL | Quando usar |
|--------|-----|-------------|
| SUCCESS | nunca (verifica SHA256) | PDF baixado com sucesso |
| FAIL_PERMANENT | 90d | 404, DOI inexistente |
| FAIL_TRANSIENT | 24h | 5xx, timeout, rate-limit |
| FAIL_RECENT | 14d | NOT_OA mas paper <30d (depósito verde tarda) |
| BLOCKED_LICENSE | 180d | TDM allowlist rejeitou (license proprietária) |
| NOT_OA | 60d | Closed Access legítimo |
| NOT_OA | 60d |

Garbage collection: `python3 scripts/cache.py --gc`

## Diagnóstico

```bash
python3 scripts/cache.py --stats            # status do cache
python3 scripts/orchestrator.py --doi DOI --output-dir /tmp --email X  # single DOI
python3 scripts/extract_pdf.py --pdf X.pdf --output X.md --quality auto
```

### Validar PDFs baixados (detectar corrompidos ou muito pequenos)

Após o batch, sempre checar integridade antes de empacotar:

```python
python3 - <<'EOF'
from pathlib import Path

pdfs = sorted(Path('/tmp/papers-out/pdfs').glob('*.pdf'))
print(f'Total PDFs: {len(pdfs)}\n')
for pdf in pdfs:
    size = pdf.stat().st_size
    header = pdf.read_bytes()[:4]
    valid = header == b'%PDF'
    flag = '✅' if (valid and size > 30_000) else '⚠️ SUSPEITO'
    print(f'{flag} {pdf.name}: {size/1024:.0f} KB  header={header}')
EOF
```

PDFs abaixo de ~30 KB são frequentemente correction notices, erratas ou páginas de acesso negado — verificar manualmente antes de incluir no bundle.

### Edge cases de stem — como o pipeline gera nomes de arquivo

O pipeline real é **dois passos**:
1. `canonical_doi(doi)` — aplica `.lower()`, normaliza Unicode, remove prefixos
2. `doi_to_filename(canon)` — substitui `[/\:.]` por `_`, remove `[^a-zA-Z0-9_-]`, trunca em 80 chars

A função `doi_to_filename()` **não** aplica lowercase sozinha — quem faz isso é `canonical_doi()`. O `batch.py` sempre canonicaliza antes de gerar o stem. O fallback manual **deve** fazer o mesmo; se não fizer, gera stems com maiúsculas que criam arquivos duplicados e não são encontrados pelo cache.

**Padrões de DOI que geram stems não-óbvios (todos via pipeline completo):**

| DOI original | Stem gerado pelo pipeline |
|---|---|
| `10.1037/0022-006X.66.1.7` | `10_1037_0022-006x_66_1_7` |
| `10.1037/0022-006X.75.4.513` | `10_1037_0022-006x_75_4_513` |
| `10.1037/0003-066X.61.4.271` | `10_1037_0003-066x_61_4_271` |
| `10.1037/0003-066X.59.7.595` | `10_1037_0003-066x_59_7_595` |
| `10.1177/014107689809135S08` | `10_1177_014107689809135s08` |
| `10.1016/S2215-0366(18)30162-7` | `10_1016_s2215-03661830162-7` |
| `10.1002/14651858.CD011119.pub2` | `10_1002_14651858_cd011119_pub2` |

**Causa:** `canonical_doi()` aplica `.lower()` → maiúsculas viram minúsculas. Parênteses `(18)` são removidos por `[^a-zA-Z0-9_-]` e o que sobra `1830162` colapsa junto ao trecho anterior.

**No fallback manual, sempre usar `canonical_doi()` antes de construir o nome:**

```python
import sys
sys.path.insert(0, '/tmp/baixar-papers/scripts')
from doi_normalize import canonical_doi, doi_to_filename

doi = "10.1037/0003-066X.61.4.271"
label = doi_to_filename(canonical_doi(doi))  # correto: 10_1037_0003-066x_61_4_271
# NÃO fazer: doi_to_filename(doi)            # errado: 10_1037_0003-066X_61_4_271
out_path = f"/tmp/papers-out/pdfs/{label}.pdf"
```

### Verificar conteúdo do PDF (não só header e tamanho)

Após download, especialmente de CORE e Wayback, confirmar que o PDF é o paper certo:

```python
import fitz
doc = fitz.open(pdf_path)
print(doc[0].get_text("text")[:400])  # título, autores, journal devem aparecer
doc.close()
```

CORE retorna falsos-positivos (~30% das vezes) com paper diferente do DOI buscado. Wayback pode retornar a versão HTML da página em vez do PDF se a URL não apontar diretamente para o arquivo `.pdf`.

---

## ⚡ Fallback manual via MCPs — para DOIs que o batch não resolve

Quando `orchestrator.py` esgota as 16 estratégias e retorna `"success": false`, executar este protocolo **antes de declarar falha**:

### Passo 1 — Semantic Scholar (mcp__mcp-research__get_paper_details)

```python
# Carrega via ToolSearch: query "select:mcp__mcp-research__get_paper_details"
mcp__mcp-research__get_paper_details(
    paper_id="10.xxxx/xxxxx",   # DOI completo
    source="semantic_scholar"
)
```

Verificar no resultado:
- `"openAccessPdf"` → se tiver `url` que não seja só o DOI paywall, tentar baixar direto
- `"externalIds"` → anotar `PubMed` ID se presente (usar no Passo 3)

### Passo 2 — Unpaywall + CORE via web_fetch

```python
# Unpaywall — lista TODAS as oa_locations, não só best_oa_location
mcp__workspace__web_fetch(
    url="https://api.unpaywall.org/v2/10.xxxx/xxxxx?email=SEU@EMAIL.COM"
)
```

No JSON retornado, varrer `oa_locations[]` em busca de entradas com `"url_for_pdf"` não-nulo — **não** apenas `best_oa_location`. Repositórios institucionais (Northumbria, VU Amsterdam, Harvard DASH) frequentemente têm PDF mesmo quando o `best` aponta só para landing page.

```python
# CORE — busca pública sem API key
mcp__workspace__web_fetch(
    url="https://api.core.ac.uk/v3/search/works?q=doi:10.xxxx/xxxxx&limit=5"
)
```

Verificar `results[].downloadUrl` E `results[].doi` — **CORE pode retornar um paper diferente** com DOI errado hospedado na mesma URL. Sempre confirmar que `results[i].doi == DOI_ALVO` antes de baixar. Se múltiplos hits, priorizar o que tem DOI exato.

### Passo 3 — PMC via elink (se tiver PubMed ID)

```python
# Verificar se há PMCID associado
mcp__workspace__web_fetch(
    url="https://eutils.ncbi.nlm.nih.gov/entrez/eutils/elink.fcgi?dbfrom=pubmed&db=pmc&id=PUBMED_ID&retmode=xml"
)
# Se retornar <DbTo>pmc</DbTo> com <Id>XXXXX</Id> → há versão PMC
# Baixar via: https://europepmc.org/articles/PMC{ID}?pdf=render
```

> ⚠️ **ARMADILHA elink:** O endpoint `elink` com `db=pmc` retorna **dois tipos de links**:
> - `pubmed_pmc` → o paper está no PMC (**este é o que queremos**)
> - `pubmed_pmc_refs` → são as referências que o paper cita, que estão no PMC — **NÃO é o paper em si**
>
> Sempre verificar o `<LinkName>` antes de usar o ID. Se só aparecer `pubmed_pmc_refs` sem `pubmed_pmc`, o paper não está no PMC.

### Passo 4 — OpenAIRE Graph

> ⚠️ **SearchAPI legada (`/search/publications`) DESLIGOU em 31/Mai/2026.** Usar exclusivamente a Graph API.
> ⚠️ **Parâmetro correto é `pid=`, não `doi=`** — `doi=` retorna HTTP 400.

```python
mcp__workspace__web_fetch(
    url="https://api.openaire.eu/graph/v1/researchProducts?pid=10.xxxx/xxxxx&pageSize=5"
)
```

Na resposta, varrer `results[].instances[].urls[]` — focar em instâncias com `license` contendo "CC" ou com URLs de repositórios institucionais (eScholarship, NARCIS, repositórios .edu). Os `originalIds[]` também listam identificadores OAI que podem ser resolvidos diretamente.

### Passo 5 — ZORA DSpace API (repositório U. Zurich — psicologia/psicoterapia)

Para papers de autores afiliados à Universidade de Zurique (especialmente grupo Flückiger/Wampold/Caspar):

```python
# 1. Descobrir o eprint ID ou UUID do bitstream via WebSearch
#    (web_fetch NÃO funciona para busca no ZORA — site é client-rendered, retorna HTML vazio)
#    WebSearch: site:zora.uzh.ch "Título do Paper" OU site:zora.uzh.ch "Autor" "ano"
#    O resultado trará URL do tipo: https://www.zora.uzh.ch/id/eprint/NNNNN

# 2. Com o eprint ID, consultar a REST API do DSpace para obter o UUID do bitstream
mcp__workspace__web_fetch(
    url="https://www.zora.uzh.ch/server/api/core/items?size=1&page=0&sort=score%2Cdesc&query=handle%3ANNNNN"
)
# Ou via handle direto:
mcp__workspace__web_fetch(
    url="https://www.zora.uzh.ch/server/api/core/items?query=dc.identifier.doi%3A10.xxxx%2Fxxxxx"
)
# Extrair UUID do bitstream de: results[].bundles[].bitstreams[].uuid

# 3. Baixar via DSpace REST API com o UUID — bypassa JS rendering e autenticação
curl -s -L -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \
  "https://www.zora.uzh.ch/server/api/core/bitstreams/{UUID}/content" \
  -o /tmp/papers-out/pdfs/LABEL.pdf
```

> **ZORA é client-rendered:** web_fetch na página de busca (`/cgi/search/`) retorna HTML vazio. A busca deve ser via WebSearch. O endpoint `/server/api/core/bitstreams/{UUID}/content` é API REST pura e retorna binário sem JS. Na prática, o UUID foi obtido nesta sessão navegando na interface web e capturando o link de download — anotar o UUID para não precisar rederescobrir.

### Passo 6 — Wayback Machine (para sites que bloqueiam curl)

Para papers hospedados em sites que retornam 403 ao curl direto (ex: societyforpsychotherapy.org):

```bash
# Verificar se há snapshot no Wayback CDX
curl -s "https://web.archive.org/cdx/search/cdx?url=URL_DO_PDF&output=json&limit=1&fl=timestamp" 2>/dev/null

# Baixar via Wayback (funciona mesmo quando o site original bloqueia)
curl -s -L -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" \
  --max-time 20 \
  "https://web.archive.org/web/2024/URL_ORIGINAL_DO_PDF" \
  -o /tmp/papers-out/pdfs/LABEL.pdf
```

> **societyforpsychotherapy.org:** Bloqueia curl com HTTP 403 mas `mcp__workspace__web_fetch` passa (retorna HTML da página, não PDF). Para obter o PDF, usar Wayback Machine com URL do formato `wp-content/uploads/YYYY/MM/FILENAME.pdf`. O slug da URL pode ser descoberto via WebSearch com `site:societyforpsychotherapy.org "Autor" "Título"`.

> **CDX instável:** O endpoint CDX retorna 503 esporadicamente. Se falhar, tentar diretamente a URL do Wayback com ano aproximado (`/web/2024/`, `/web/2023/`) sem checar o CDX primeiro.

### Passo 7 — Download direto via Python

Uma vez encontrada uma URL candidata com PDF real:

```python
python3 - <<'EOF'
import sys, urllib.request
sys.path.insert(0, '/tmp/baixar-papers/scripts')
from doi_normalize import canonical_doi, doi_to_filename

doi = "10.xxxx/xxxxx"
url = "URL_ENCONTRADA"

# SEMPRE canonicalizar antes de construir o path — canonical_doi() aplica lowercase
# doi_to_filename() sozinho NÃO aplica lowercase; sem canonical, DOIs com maiúsculas
# geram stems diferentes dos que o batch.py geraria, criando duplicatas no disco
label = doi_to_filename(canonical_doi(doi))
out = f"/tmp/papers-out/pdfs/{label}.pdf"

# User-Agent completo de browser — essencial para eScholarship e repositórios institucionais
# que retornam 403 com User-Agent genérico.
# ResearchGate: bloqueado por Cloudflare (error 1020) independente do UA — não tentável.
headers = {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    "Accept": "application/pdf,*/*",
}
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=20) as r:
    data = r.read()
assert data[:4] == b"%PDF", f"Não é PDF: {data[:50]}"
assert len(data) > 50_000, f"Suspeito (correction notice?): {len(data)} bytes"
open(out, "wb").write(data)
print(f"[OK] {len(data)//1024} KB → {out}")

# Verificar conteúdo — confirmar que é o paper certo (não um falso-positivo do CORE)
import fitz
doc = fitz.open(out)
print("--- Primeiros 400 chars ---")
print(doc[0].get_text("text")[:400])
doc.close()
EOF
```

> **eScholarship (UC):** URLs `escholarship.org/content/qt.../qt....pdf` retornam 403 com User-Agent simples mas funcionam com User-Agent de Chrome completo. Adicionar `?t=QUALQUERCOISA` contorna cache de CDN.

> **CORE falso-positivo:** CORE frequentemente retorna um paper com DOI diferente do buscado (arquivo errado hospedado). Sempre checar os primeiros 400 chars do texto para confirmar título/autores antes de aceitar como válido.

### Extração Markdown quando package_bundle.py travar

`package_bundle.py` roda extração pesada internamente e frequentemente excede o timeout de 45s da shell. Usar diretamente o `fitz` (já instalado com pymupdf):

> ⚠️ `fitz.Page.get_text()` **não aceita `"markdown"`** como formato — retorna `AssertionError`. Formatos válidos: `"text"` (preferido para NotebookLM), `"html"`, `"dict"`, `"rawdict"`, `"blocks"`.

```python
python3 - <<'EOF'
import fitz
from pathlib import Path

pdf_path = "/tmp/papers-out/pdfs/LABEL.pdf"
out_path = "/tmp/papers-out/markdown/LABEL.md"
doi = "10.xxxx/xxxxx"
title = "Título do paper"

doc = fitz.open(pdf_path)
pages = [page.get_text("text") for page in doc]  # "text", não "markdown"
doc.close()
md = "\n\n".join(pages)
Path(out_path).write_text(
    f'---\ndoi: "{doi}"\ntitle: "{title}"\n---\n\n' + md,
    encoding="utf-8"
)
print(f"[OK] {len(md):,} chars, {len(pages)} páginas")
EOF
```

Para processar múltiplos PDFs sem travar o timeout, processar **um por chamada de bash** ou em lotes de 2-3 (cada PDF leva ~5-15s dependendo de OCR).

Depois de extrair todos os MDs, empacotar manualmente:

```python
python3 - <<'EOF'
import zipfile
from pathlib import Path

out_zip = Path("/sessions/.../mnt/cowork-assets/NOME.zip")
pdfs_dir = Path("/tmp/papers-out/pdfs")
md_dir = Path("/tmp/papers-out/markdown")

with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED) as zf:
    for pdf in sorted(pdfs_dir.glob("*.pdf")):
        zf.write(pdf, f"pdfs/{pdf.name}")
    for md in sorted(md_dir.glob("*.md")):
        zf.write(md, f"markdown/{md.name}")
    if Path("/tmp/papers-out/_summary.json").exists():
        zf.write("/tmp/papers-out/_summary.json", "_summary.json")

print(f"[OK] {out_zip.stat().st_size/1024/1024:.1f} MB")
EOF
```

> **Nota:** Substitua o caminho de exemplo pelo diretório real onde estão seus arquivos no seu ambiente.

## DOIs definitivamente sem OA (APA paywall puro)

Estes DOIs foram exaustivamente verificados em Mai/2026 via Unpaywall, OpenAlex, Semantic Scholar, PMC elink, CORE, OpenAIRE, ZORA, Wayback Machine, scholar.archive.org e ResearchGate. Nenhuma cópia OA encontrada. Só acessíveis com acesso institucional APA ou compra individual:

| DOI | Título | Journal |
|-----|--------|---------|
| `10.1037/pst0000240` | Advantages of developing clinical practice guidelines using international standards | Psychotherapy |
| `10.1037/pst0000167` | Collecting and delivering progress feedback: A meta-analysis of routine outcome monitoring | Psychotherapy |
| `10.1037/amp0001363` | Broadening the evidentiary basis for clinical practice guidelines | American Psychologist |
| `10.1037/ccp0000904` | Data-informed psychological therapy, measurement-based care, and precision mental health | J Consulting & Clinical Psychology |
| `10.1037/law0000448` | A scoping review and meta-analyses of clinical override use in structured risk assessments | Psychology, Public Policy, and Law |

Para estes, a única alternativa disponível na sandbox é **solicitar ao usuário acesso via biblioteca institucional ou conta APA Member**.

## Refs

- `references/strategies.md` — detalhamento da chain
- `references/tools.md` — projetos similares (paper-search-mcp, PaperQA2 etc)
- `references/notebooklm-format.md` — específicos NotebookLM
- `references/known-blocks.md` — Cloudflare/Akamai patterns
- `references/capability-matrix.md` — versão expandida da tabela acima
- `CHANGELOG.md` — diff v1→v2, v2.1→v2.2

## Patch v2.3 → v2.3.1 — EXECUTADO em 10/06/2026

v2.3 aplicado na sessão Cowork (reconstrução de v2_1 + patches 08/Mai; zip
v2_2 original perdido). v2.3.1 aplicado em revisão de auditoria: o
citation_traverse.py tinha escapado da migração e chamava o OpenAlex com
mailto e chave opcional; corrigido pra api_key obrigatória via get_api_key
(fail-fast). O crossref.py mantém mailto de propósito (a aposentadoria foi só
no OpenAlex). Detalhes no CHANGELOG.md dentro do zip.

## Patch v2.3.1 → v2.3.2 — EXECUTADO em 10/06/2026 (patch interno)

- `references/capability-matrix.md` RECONSTRUÍDO (única perda da consolidação);
  inclui matriz por tier validada contra o código + apêndice de divergências.
- description deste SKILL.md aspeada (parseia em YAML estrito; antes `ATENCAO:
  CORE` quebrava PyYAML — loader do Claude era leniente, mas agora está blindado).
- cache.py e tools.md corrigidos: “5-state” → 6 estados (o schema real lista 6).
- Nenhuma mudança de lógica em scripts.
