---
name: browser-research-domains
description: >
  Atajos por dominio para research técnico que evitan abrir un browser cuando
  hay API documentada disponible. Cubre GitHub, ArXiv, Hacker News, Stack
  Overflow, PubMed, OpenAlex y SEC EDGAR con endpoint correcto, batch fetch,
  rate limits y gotchas. Cargar cuando investigador-swl, agent-browser o un
  flujo de research consume múltiples páginas de cualquiera de estos dominios
  y se quiere reducir tokens y latencia.
version: "1.0.0"
herramientasPermitidas: [Read, Bash, WebFetch]
evolved: false
fuente: "browser-use/browser-harness — domain-skills (MIT License, 2026)"
evolvable: true
exclusiones:
  - "No cargar si el research toca un dominio no listado aquí — usar agent-browser directo."
  - "No cargar para automatización con login/interacción — esto es read-only research vía APIs públicas."
  - "No cargar para tareas que requieren contenido renderizado por JS — usar agent-browser o browser-interaction-patterns."
  - "No cargar para descarga de PDFs académicos — usar swl-markitdown sobre la URL del PDF directo."
---

# Skill: browser-research-domains

Para los 7 dominios cubiertos aquí, **NUNCA abrir un browser**. Todo el dato
está accesible vía `http_get` + API REST/Atom/XML, sin auth o con auth opcional.
Reduce tokens 20-50× y latencia 5-20×.

Adaptado de `browser-use/browser-harness/agent-workspace/domain-skills/`
(MIT License). Los snippets son agnósticos al runtime — funcionan en cualquier
contexto Python que tenga `urllib.request` o equivalente. En SWL, llamar via
`agent-browser` CLI cuando sea necesario, o directamente con `Bash + curl/python`
si la situación lo permite.

## Cuándo cargar

- Research técnico que tocará github, arxiv, hackernews, stackoverflow, pubmed,
  openalex o sec edgar.
- Necesidad de batch-fetch (10+ items) de cualquiera de estos dominios.
- `agent-browser` está siendo invocado para algo que tiene API documentada.

## Cuándo NO cargar

- Research que cruza dominios NO listados aquí.
- Tareas de automatización con interacción (login, formularios).
- Descarga de PDFs académicos — usar `swl-markitdown` sobre la URL del PDF.

## Regla universal — API antes que browser

Para los 7 dominios cubiertos, el browser solo es necesario para:
- GitHub trending page (server-side rendered, sin API equivalente).
- Render de MathJax en Stack Overflow (raro).
- Cualquier contenido fuera de API pública.

**Todo lo demás se hace con `http_get` + JSON/XML.** Ganancia típica:
- `arxiv` browser: 5-8s por paper / API batch: 1.9s por 10 papers (~25× más rápido).
- `hackernews` browser: 3-8s por página / `http_get` regex: 170ms.

---

## 1. GitHub

Mezcla REST API + browser. El browser solo para `/trending` (server-side
rendered, sin equivalente API). Todo lo demás vía REST.

### Metadata de repo

```python
import json
data = json.loads(http_get("https://api.github.com/repos/{owner}/{repo}"))
# Campos: stargazers_count, forks_count, description, language, topics,
# open_issues_count, created_at, updated_at, pushed_at, default_branch,
# license, homepage, visibility
```

### Contenido de archivos — `raw.githubusercontent.com`

Sin rate limit, sin auth, sin base64.

```python
readme = http_get("https://raw.githubusercontent.com/owner/repo/main/README.md")
```

### Búsqueda de repos

```python
results = json.loads(http_get(
    "https://api.github.com/search/repositories"
    "?q=browser+automation+language:python&sort=stars&per_page=10"
))
```

Rate limit search: **10 req/min unauthenticated** (separado del core 60/hora).

### Trending page — único caso con browser

```python
import json
goto_url("https://github.com/trending")  # o /trending/python?since=weekly
wait_for_load()
wait(2)  # hidratación React tras readyState=complete

result = js("""
(function(){
  var rows = Array.from(document.querySelectorAll('article.Box-row'));
  return JSON.stringify(rows.map(function(el){
    var h2link = el.querySelector('h2 a');
    var starLink = el.querySelector('a[href*="/stargazers"]');
    return {
      name: h2link?.innerText.trim().replace(/\\s+/g,' '),
      url: h2link ? 'https://github.com' + h2link.getAttribute('href') : null,
      stars_total: starLink?.innerText.trim()
    };
  }));
})()
""")
```

### Auth

Sin token: 60 req/hora core, 10 req/min search.
Con `GITHUB_TOKEN`: 5,000/hora ambos.

```python
headers = {"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
           "X-GitHub-Api-Version": "2022-11-28"}
```

### Gotchas

- 404 raises `urllib.error.HTTPError`, no JSON error.
- Code search (`/search/code`) requiere auth, devuelve 401 sin token.
- Trending stars vienen como string `"4,548"` — `int(s.replace(',', ''))`.

---

## 2. ArXiv

**NUNCA usar browser para ArXiv.** Todo accesible via Atom API. `id_list`
soporta batch hasta 200 IDs por call.

### Batch fetch (10× más rápido que paralelo)

```python
import xml.etree.ElementTree as ET
NS = {'atom': 'http://www.w3.org/2005/Atom',
      'arxiv': 'http://arxiv.org/schemas/atom'}

ids = ['1706.03762', '1810.04805', '2005.14165']
xml = http_get(
    f"http://export.arxiv.org/api/query?id_list={','.join(ids)}"
    f"&max_results={len(ids)}"
)
root = ET.fromstring(xml)
for e in root.findall('atom:entry', NS):
    arxiv_id = e.find('atom:id', NS).text.split('/')[-1]
    title = e.find('atom:title', NS).text.strip()
    abstract = e.find('atom:summary', NS).text.strip()
    pdf_link = next((l.get('href') for l in e.findall('atom:link', NS)
                     if l.get('title') == 'pdf'), None)
```

Confirmed: batch 10 IDs → 1.91s. Paralelo 10 single calls → 6.34s.

### Search por categoría

```python
xml = http_get(
    "http://export.arxiv.org/api/query"
    "?search_query=ti:transformer+AND+cat:cs.LG"
    "&max_results=10&sortBy=submittedDate&sortOrder=descending"
)
```

Field prefixes: `ti:` title, `au:` author, `abs:` abstract, `cat:` category,
`all:` all fields. Boolean: `AND`/`OR`/`ANDNOT`.

### URL construction

```python
arxiv_id = "1706.03762v7"
pdf_versioned = f"https://arxiv.org/pdf/{arxiv_id}"
pdf_latest = f"https://arxiv.org/pdf/{re.sub(r'v\\d+$', '', arxiv_id)}"
```

### Gotchas

- Rate limit: 3s entre requests para bulk crawling. Bursts rápidos de ~10
  funcionan sin block.
- `atom:id` es URL completa `http://arxiv.org/abs/1706.03762v7` — siempre
  split `/[-1]`.
- Batch `id_list` retorna entries en orden impredecible — indexar por ID, no
  por posición.
- ~5% de papers no tienen `atom:summary` — guard con `if el is not None`.
- `max_results` cap es 2000 por call. Pagination con `start` offset + 3s sleep.

---

## 3. Hacker News

Tres paths, todos sin browser:

| Goal | Approach | Latency |
|------|----------|---------|
| Front page (30 stories) | `http_get` + regex | ~170ms |
| Historical / keyword search | Algolia API | ~400ms |
| Comment tree completo | Algolia items API | ~300ms |
| Item específico | Firebase API | ~200ms |
| 500 ranked IDs | Firebase topstories | ~200ms |

### Front page scrape (más rápido para real-time)

```python
import re, html as htmllib

page = http_get("https://news.ycombinator.com")
story_ids = re.findall(r'<tr class="athing submission" id="(\\d+)">', page)
titles_urls = re.findall(
    r'class="titleline"[^>]*><a href="([^"]*)"[^>]*>(.*?)</a>', page
)
# titles DEBEN pasar por html.unescape() — contienen &#x27; &amp; etc.
```

### Algolia (search + nested comments)

```python
import json

# Búsqueda por keyword
data = json.loads(http_get(
    "https://hn.algolia.com/api/v1/search"
    "?query=llm&tags=story&hitsPerPage=20"
))

# Más recientes
data = json.loads(http_get(
    "https://hn.algolia.com/api/v1/search_by_date"
    "?tags=story&hitsPerPage=20"
))

# Thread completo con árbol anidado
thread = json.loads(http_get(
    "https://hn.algolia.com/api/v1/items/47806725"
))
# thread['children'] = top-level comments con .children anidados
```

### Firebase oficial

```python
top = json.loads(http_get("https://hacker-news.firebaseio.com/v0/topstories.json"))
item = json.loads(http_get(f"https://hacker-news.firebaseio.com/v0/item/{id}.json"))
user = json.loads(http_get(f"https://hacker-news.firebaseio.com/v0/user/{u}.json"))
```

### Gotchas

- Titles tienen entities HTML (`&#x27;`, `&amp;`) — siempre `html.unescape()`.
- Job posts no tienen score ni author — `scores_by_id.get(sid)` retorna `None`.
- Algolia comment fields usan `comment_text`, NO `text`.
- Firebase 500 items secuenciales = ~100s; Algolia con `tags=front_page` es
  mucho más rápido para bulk.

---

## 4. Stack Overflow

Stack Exchange API v2.3, JSON, sin browser. Rate limit duro:
- **300 req/día por IP** sin key.
- **10,000 req/día** con key.

### Top questions por tag

```python
data = json.loads(http_get(
    "https://api.stackexchange.com/2.3/questions"
    "?order=desc&sort=votes&tagged=python&site=stackoverflow"
    "&pagesize=5&filter=withbody"
))
print("Quota:", data['quota_remaining'])
```

`filter=withbody` es **obligatorio** para incluir body — sin él, el campo
simplemente no existe (sin error, sin warning).

### Batch IDs (semicolon-delimited, hasta 100)

```python
data = json.loads(http_get(
    "https://api.stackexchange.com/2.3/questions/231767;419163;394809"
    "?site=stackoverflow&filter=withbody"
))
```

### Decoding

- `title`: tiene entities (`&quot;`, `&#39;`) → `html.unescape()`.
- `body`: HTML completo → `HTMLParser` para texto plano.
- `display_name`, `tags`: plain text, no decode.

### Multi-site

`site=stackoverflow` se puede sustituir por: `superuser`, `serverfault`,
`askubuntu`, `unix`, `datascience`, `math`. Mismo API, mismo quota pool.

### Gotchas

- Quota es por IP, resets midnight UTC. 6 tests consumen ~27 quota.
- Verificar `data.get('backoff')` y dormir si retorna int.
- Pagesize max 100. `page=` 1-indexed.
- Errors raise `HTTPError` exception, no JSON body accesible.

---

## 5. PubMed

NCBI E-utilities REST. **NUNCA browser.** Sin API key: 3 req/s. Con free key:
10 req/s.

### Pipeline ESearch → ESummary (más común)

```python
import json

# Step 1: search → PMIDs
search = json.loads(http_get(
    "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
    "?db=pubmed&term=deep+learning+radiology&retmax=10&retmode=json"
))
pmids = search['esearchresult']['idlist']

# Step 2: metadata batch
summary = json.loads(http_get(
    f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
    f"?db=pubmed&id={','.join(pmids)}&retmode=json"
))
result = summary['result']
for uid in result['uids']:
    art = result[uid]
    title = art['title']
    pubdate = art['pubdate']  # '2026 Apr 18'
    authors = [a['name'] for a in art['authors']]  # abreviados 'Last I'
    doi = {x['idtype']: x['value'] for x in art['articleids']}.get('doi')
```

### EFetch XML (full abstract + MeSH + author full names)

```python
import xml.etree.ElementTree as ET

raw = http_get(
    "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
    "?db=pubmed&id=41999029&retmode=xml&rettype=abstract"
)
root = ET.fromstring(raw)
for art in root.findall('.//PubmedArticle'):
    mc = art.find('MedlineCitation')
    pmid = mc.find('PMID').text
    article = mc.find('Article')
    title = ''.join(article.find('ArticleTitle').itertext()).strip()
    # Abstract puede ser estructurado (BACKGROUND/METHODS/RESULTS/CONCLUSION)
    abstract_el = article.find('Abstract')
    if abstract_el is not None:
        sections = []
        for t in abstract_el.findall('AbstractText'):
            label = t.get('Label', '')
            text = ''.join(t.itertext()).strip()
            sections.append(f"[{label}] {text}" if label else text)
        abstract = ' '.join(sections)
```

### Bulk con WebEnv (>10k results)

```python
search = json.loads(http_get(
    "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
    "?db=pubmed&term=CRISPR&retmax=0&retmode=json&usehistory=y"
))
webenv = search['esearchresult']['webenv']
query_key = search['esearchresult']['querykey']

for start in range(0, 1000, 200):
    raw = http_get(
        f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
        f"?db=pubmed&query_key={query_key}&WebEnv={webenv}"
        f"&retstart={start}&retmax=200&retmode=xml&rettype=abstract"
    )
```

### Gotchas

- `count` es string, no int. `int(search['esearchresult']['count'])`.
- EFetch retmode debe ser `xml`, NO `json` (json devuelve texto MEDLINE plano).
- `ArticleTitle` puede tener tags embebidos (`<i>`, `<sub>`) — usar `itertext()`.
- ~15% de articles sin abstract — guard `if abstract_el is not None`.
- Author puede ser `CollectiveName` (consortium) en lugar de `LastName/ForeName` —
  check `CollectiveName` primero.
- ELink `pubmed_pubmed` (related) está roto persistentemente — usar DOI como
  fallback.

---

## 6. OpenAlex

260M+ works, 90M+ authors, JSON API, sin auth.

**Siempre incluir `mailto=` para usar polite pool** (10 req/s, más confiable).

### Search papers

```python
import json

data = json.loads(http_get(
    "https://api.openalex.org/works"
    "?search=transformer+attention"
    "&per-page=5&sort=cited_by_count:desc"
    "&select=id,doi,display_name,publication_year,cited_by_count,open_access"
    "&mailto=you@example.com"
))
for w in data["results"]:
    bare_id = w["id"].split("/")[-1]  # W2626778328
    print(bare_id, w["publication_year"], w["cited_by_count"])
```

### Por DOI

```python
w = json.loads(http_get(
    "https://api.openalex.org/works/https://doi.org/10.1038/nature14539"
    "?mailto=you@example.com"
))
```

### Reconstruir abstract (inverted index)

```python
w = json.loads(http_get(
    "https://api.openalex.org/works/W2626778328"
    "?select=id,abstract_inverted_index&mailto=you@example.com"
))
aii = w.get("abstract_inverted_index") or {}
words_pos = [(pos, word) for word, positions in aii.items() for pos in positions]
abstract = " ".join(word for _, word in sorted(words_pos))
```

### Citation traversal

```python
# Forward citations
citing = json.loads(http_get(
    f"https://api.openalex.org/works?filter=cites:{paper_id}"
    "&per-page=5&sort=cited_by_count:desc&mailto=you@example.com"
))

# Backward references
paper = json.loads(http_get(
    f"https://api.openalex.org/works/{paper_id}"
    "?select=referenced_works&mailto=you@example.com"
))
refs = paper.get("referenced_works", [])
```

### Cursor pagination (bulk >10k)

```python
import urllib.parse
cursor = "*"
while True:
    encoded = urllib.parse.quote(cursor, safe="")
    data = json.loads(http_get(
        f"https://api.openalex.org/works?filter={flt}"
        f"&per-page=200&cursor={encoded}&mailto=you@example.com"
    ))
    if not data.get("results"): break
    # process
    cursor = data["meta"].get("next_cursor")
    if not cursor: break
```

### Filter syntax

`filter=author.id:A5108093963,publication_year:>2020,open_access.is_oa:true`
- AND: comma
- OR: pipe `2022|2023`
- Negation: `!2020`
- Range: `>1000`, `<2010`, `100-500`

### Entity ID prefixes

`W` Work, `A` Author, `I` Institution, `S` Source, `C` Concept, `T` Topic,
`F` Funder, `P` Publisher.

### Gotchas

- `id` field es URL completa `https://openalex.org/W2626778328` — siempre
  `.split("/")[-1]` para bare ID.
- DOI lookup usa URL completa: `/works/https://doi.org/...`, NO `/works/10.1038/...`.
- Page-based pagination hard stops at 10,000 results. Usar `cursor=*` para más.
- `cursor=*` debe URL-encodearse: `urllib.parse.quote(cursor, safe="")`.
- `group_by` y `page` incompatibles.
- `abstract_inverted_index` puede ser `null` para closed-access papers.
- `select=` reduce payload ~90% — usar en bulk harvests.

---

## 7. SEC EDGAR

Datos públicos sin auth. **`www.sec.gov` requiere User-Agent custom** o devuelve 403.

```python
UA = {"User-Agent": "swl-ses research@example.com"}
# Formato requerido: "CompanyName contact@email.com"
```

### Ticker → CIK

```python
import json
tickers = json.loads(http_get(
    "https://www.sec.gov/files/company_tickers.json", headers=UA
))
aapl = next(v for v in tickers.values() if v['ticker'] == 'AAPL')
cik = str(aapl['cik_str']).zfill(10)  # "0000320193"
```

### Submissions (~1000 filings recientes)

```python
data = json.loads(http_get(
    f"https://data.sec.gov/submissions/CIK{cik}.json", headers=UA
))
recent = data['filings']['recent']
filings_10k = [
    (f, d, a, doc)
    for f, d, a, doc in zip(
        recent['form'], recent['filingDate'],
        recent['accessionNumber'], recent['primaryDocument']
    )
    if f in ('10-K', '10-Q')
]
```

### XBRL — un concepto sobre tiempo

```python
data = json.loads(http_get(
    f"https://data.sec.gov/api/xbrl/companyconcept/CIK{cik}"
    "/us-gaap/Assets.json", headers=UA
))
entries = data['units']['USD']
# Deduplicar — restatements multiplican entries por periodo
seen = {}
for e in entries:
    if e.get('form') == '10-K' and e.get('fp') == 'FY':
        end = e['end']
        if end not in seen or e['filed'] > seen[end]['filed']:
            seen[end] = e
```

### Cross-company (XBRL frames)

```python
data = json.loads(http_get(
    "https://data.sec.gov/api/xbrl/frames/us-gaap"
    "/RevenueFromContractWithCustomerExcludingAssessedTax/USD/CY2024.json",
    headers=UA
))
# data['data'] = lista de todas las companies para ese concepto/periodo
```

### Full-text search

```python
data = json.loads(http_get(
    "https://efts.sec.gov/LATEST/search-index"
    "?q=%22climate+risk%22&forms=10-K&dateRange=custom&startdt=2024-01-01",
    headers=UA
))
# efts.sec.gov acepta Mozilla/5.0 default
```

### Rate limit

**10 req/s** documentado. `max_workers ≤ 8` para `ThreadPoolExecutor`.

### Gotchas

- `www.sec.gov` con `Mozilla/5.0` default devuelve 403. SIEMPRE `headers=UA`.
- `data.sec.gov` y `efts.sec.gov` son más permisivos pero usar UA igual por
  política.
- XBRL contiene duplicates per period — dedup por `end` con latest `filed`.
- Revenue concept varía: `RevenueFromContractWithCustomerExcludingAssessedTax`
  (post-2018) vs `SalesRevenueNet` (older).
- `fp` para anuales es `'FY'`; quarterly también aparecen en 10-K, filtrar
  ambos `form == '10-K'` AND `fp == 'FY'`.
- `companyfacts.json` es ~5MB — para single metric usar `companyconcept`.
- CIK format: `cik_str` int en company_tickers; APIs requieren `str(cik).zfill(10)`.

---

## Tabla de decisión rápida

| Dominio | Browser necesario | Endpoint preferido |
|---------|-------------------|---------------------|
| GitHub | Solo para /trending | api.github.com (60/h sin token) |
| ArXiv | NUNCA | export.arxiv.org/api/query (Atom) |
| Hacker News | NUNCA | hn.algolia.com / hacker-news.firebaseio.com |
| Stack Overflow | NUNCA | api.stackexchange.com (300/día sin key) |
| PubMed | NUNCA | eutils.ncbi.nlm.nih.gov (3/s sin key) |
| OpenAlex | NUNCA | api.openalex.org (con mailto) |
| SEC EDGAR | NUNCA | data.sec.gov + UA obligatoria |

## Patrón canónico

Para los 7 dominios, el patrón es:

1. Skip `goto_url`, `wait_for_load`, `capture_screenshot`.
2. Construir URL del API endpoint con query params.
3. `http_get` (o `WebFetch` en SWL si el agente no tiene CLI Python).
4. Parsear JSON / XML según corresponda.
5. Verificar quota / rate limit response field si la API lo provee.

Si el dominio NO está en esta lista, default a `agent-browser` + `browser-interaction-patterns`.

---

## Relación con otras skills

- **`agent-browser`**: cuando el dominio NO está aquí o requiere JS/login.
- **`browser-interaction-patterns`**: patrones de bajo nivel CDP cuando hay
  que automatizar UI.
- **`web-fetcher-routing`**: orquesta WebFetch vs agent-browser por tipo de URL.
- **`swl-markitdown`**: para PDFs académicos linked desde papers (arxiv, pubmed).

<!-- Adaptado de browser-use/browser-harness bajo MIT License (browser-use, 2026). -->
