Claude Skill

url

Fetch and extract text from web URLs

LLM Mart · 0 points · 21 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download axoviq-ai-synthadoc-synthadoc_skills_url-8dee0ee.zip · 3 KB
Part of axoviq-ai/synthadoc — 10 skills

Install

skills CLI npx skills add https://github.com/axoviq-ai/synthadoc/tree/main/synthadoc/skills/url
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install axoviq-ai-synthadoc@llmmart
Git git clone https://github.com/axoviq-ai/synthadoc.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole axoviq-ai/synthadoc collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

URL Skill

Fetches a web URL using httpx, strips navigation/script/style tags with BeautifulSoup, and returns clean body text. PDF URLs are extracted with pypdf (primary) and pdfminer.six (fallback).

Setup

pip install httpx beautifulsoup4

# Optional — needed only if you ingest PDF URLs:
pip install pypdf pdfminer.six

Standalone usage

import asyncio
from synthadoc.skills.url.scripts.main import UrlSkill

skill = UrlSkill()

async def main():
    result = await skill.extract("https://example.com/article")
    print(result.text)          # clean body text
    print(result.metadata)      # {"url": "https://..."}

asyncio.run(main())

DomainBlockedException is raised when the site returns HTTP 401, 403, or 429. Catch it to log and skip the domain:

from synthadoc.skills.base import DomainBlockedException

try:
    result = await skill.extract(url)
except DomainBlockedException as e:
    print(f"Blocked: {e.domain} (HTTP {e.status_code})")

When this skill is used

  • Source starts with https:// or http://
  • User intent contains: fetch url, web page, website
Files (synthadoc)
  • scripts
    • main.py 5.5 KB
      # SPDX-License-Identifier: AGPL-3.0-or-later
      # Copyright (C) 2026 Paul Chen / axoviq.com
      import logging
      import tempfile
      import httpx
      from bs4 import BeautifulSoup
      from urllib.parse import urlparse
      from synthadoc.skills.base import BaseSkill, ExtractedContent, SkillMeta, DomainBlockedException
      
      logger = logging.getLogger(__name__)
      
      _HEADERS = {
          "User-Agent": (
              "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
              "AppleWebKit/537.36 (KHTML, like Gecko) "
              "Chrome/124.0.0.0 Safari/537.36"
          ),
      }
      
      # HTTP status codes that indicate bot/access blocking (not transient errors)
      _BLOCKED_STATUSES = {403, 401, 429}
      
      # macOS Python (python.org installer) doesn't use the system keychain.
      # certifi ships its own CA bundle that covers the vast majority of public sites.
      import ssl as _ssl
      try:
          import certifi as _certifi
          _SSL_CONTEXT = _ssl.create_default_context(cafile=_certifi.where())
      except ImportError:
          _SSL_CONTEXT = _ssl.create_default_context()  # fall back to system certs
      
      
      class UrlSkill(BaseSkill):
          meta = SkillMeta(name="url", description="Fetch and extract text from web URLs",
                           extensions=["https://", "http://"])
      
          def __init__(self, fetch_timeout: int = 30) -> None:
              super().__init__()
              self._fetch_timeout = fetch_timeout
      
          async def extract(self, source: str) -> ExtractedContent:
              try:
                  async with httpx.AsyncClient(
                      follow_redirects=True, timeout=self._fetch_timeout, headers=_HEADERS, verify=_SSL_CONTEXT
                  ) as client:
                      resp = await client.get(source)
              except httpx.ConnectError as exc:
                  err_str = str(exc)
                  if "CERTIFICATE_VERIFY_FAILED" in err_str or "SSL" in err_str.upper():
                      # SSL errors won't resolve on retry — skip gracefully
                      logger.warning("SSL verification failed for %s — skipping", source)
                      return ExtractedContent(text="", source_path=source,
                                              metadata={"url": source, "ssl_error": True})
                  raise  # non-SSL ConnectError — let orchestrator handle (retry with backoff)
              if resp.status_code in _BLOCKED_STATUSES:
                  domain = urlparse(source).hostname or source
                  raise DomainBlockedException(
                      domain=domain, url=source, status_code=resp.status_code
                  )
              resp.raise_for_status()
              content_type = resp.headers.get("content-type", "")
              is_pdf = "application/pdf" in content_type or source.lower().endswith(".pdf")
              if is_pdf:
                  import asyncio
                  return await asyncio.to_thread(self._extract_pdf_response, resp.content, source)
              html = resp.text
      
              soup = BeautifulSoup(html, "html.parser")
              for tag in soup(["script", "style", "nav", "footer"]):
                  tag.decompose()
              return ExtractedContent(text=soup.get_text(separator="\n", strip=True),
                                      source_path=source, metadata={"url": source})
      
          def _extract_pdf_response(self, content: bytes, source: str) -> ExtractedContent:
              """Write PDF bytes to a temp file and extract text via pypdf with pdfminer fallback.
      
              Truncated or malformed PDFs (PdfStreamError) are handled gracefully:
              pypdf is tried first; on failure pdfminer.six is tried; if both fail
              an empty ExtractedContent is returned so the job completes as 'skipped'
              rather than dying after 3 retries.
              """
              import os
              import pypdf
      
              logging.getLogger("pypdf").setLevel(logging.ERROR)
      
              with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
                  tmp.write(content)
                  tmp_path = tmp.name
      
              try:
                  # --- pypdf attempt ---
                  try:
                      parts = []
                      with open(tmp_path, "rb") as f:
                          reader = pypdf.PdfReader(f)
                          num_pages = len(reader.pages)
                          for page in reader.pages:
                              t = page.extract_text()
                              if t:
                                  parts.append(t)
                      text = "\n".join(parts)
                      if text.strip():
                          return ExtractedContent(text=text, source_path=source,
                                                  metadata={"url": source, "pages": num_pages})
                      # Empty yield — fall through to pdfminer
                  except Exception as pypdf_err:
                      logger.warning("pypdf failed for %s (%s) — trying pdfminer fallback", source, pypdf_err)
      
                  # --- pdfminer fallback ---
                  try:
                      from pdfminer.high_level import extract_text as pdfminer_extract
                      text = pdfminer_extract(tmp_path)
                      num_pages = 0  # pdfminer doesn't report page count cheaply
                      if text.strip():
                          return ExtractedContent(text=text, source_path=source,
                                                  metadata={"url": source, "pages": num_pages})
                  except Exception as pm_err:
                      logger.warning("pdfminer fallback also failed for %s (%s)", source, pm_err)
      
                  # Both extractors failed — return empty so IngestAgent skips gracefully
                  logger.warning("PDF at %s could not be extracted (truncated or malformed) — skipping", source)
                  return ExtractedContent(text="", source_path=source,
                                          metadata={"url": source, "pages": 0})
              finally:
                  os.unlink(tmp_path)
      
    • __init__.py 0 B
  • requirements.txt 90 B
    httpx
    beautifulsoup4
    # Optional — only needed for PDF URL ingestion:
    pypdf
    pdfminer.six
    
  • SKILL.md 1.5 KB
    ---
    name: url
    version: "1.0"
    description: Fetch and extract text from web URLs
    entry:
      script: scripts/main.py
      class: UrlSkill
    triggers:
      extensions:
        - "https://"
        - "http://"
      intents:
        - "fetch url"
        - "web page"
        - "website"
    requires:
      - httpx
      - beautifulsoup4
    author: axoviq.com
    license: AGPL-3.0-or-later
    ---
    
    # URL Skill
    
    Fetches a web URL using `httpx`, strips navigation/script/style tags with
    `BeautifulSoup`, and returns clean body text. PDF URLs are extracted with
    `pypdf` (primary) and `pdfminer.six` (fallback).
    
    ## Setup
    
    ```bash
    pip install httpx beautifulsoup4
    
    # Optional — needed only if you ingest PDF URLs:
    pip install pypdf pdfminer.six
    ```
    
    ## Standalone usage
    
    ```python
    import asyncio
    from synthadoc.skills.url.scripts.main import UrlSkill
    
    skill = UrlSkill()
    
    async def main():
        result = await skill.extract("https://example.com/article")
        print(result.text)          # clean body text
        print(result.metadata)      # {"url": "https://..."}
    
    asyncio.run(main())
    ```
    
    `DomainBlockedException` is raised when the site returns HTTP 401, 403, or
    429. Catch it to log and skip the domain:
    
    ```python
    from synthadoc.skills.base import DomainBlockedException
    
    try:
        result = await skill.extract(url)
    except DomainBlockedException as e:
        print(f"Blocked: {e.domain} (HTTP {e.status_code})")
    ```
    
    ## When this skill is used
    
    - Source starts with `https://` or `http://`
    - User intent contains: `fetch url`, `web page`, `website`
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related