diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 00000000..5ff1b78a --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -0,0 +1,81 @@ +name: Docker Release +on: + release: + types: [published] + push: + tags: + - 'docker-rebuild-v*' # Allow manual Docker rebuilds via tags + +jobs: + docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract version from release or tag + id: get_version + run: | + if [ "${{ github.event_name }}" == "release" ]; then + # Triggered by release event + VERSION="${{ github.event.release.tag_name }}" + VERSION=${VERSION#v} # Remove 'v' prefix + else + # Triggered by docker-rebuild-v* tag + VERSION=${GITHUB_REF#refs/tags/docker-rebuild-v} + fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "Building Docker images for version: $VERSION" + + - name: Extract major and minor versions + id: versions + run: | + VERSION=${{ steps.get_version.outputs.VERSION }} + MAJOR=$(echo $VERSION | cut -d. -f1) + MINOR=$(echo $VERSION | cut -d. -f1-2) + echo "MAJOR=$MAJOR" >> $GITHUB_OUTPUT + echo "MINOR=$MINOR" >> $GITHUB_OUTPUT + echo "Semantic versions - Major: $MAJOR, Minor: $MINOR" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push Docker images + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + unclecode/crawl4ai:${{ steps.get_version.outputs.VERSION }} + unclecode/crawl4ai:${{ steps.versions.outputs.MINOR }} + unclecode/crawl4ai:${{ steps.versions.outputs.MAJOR }} + unclecode/crawl4ai:latest + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Summary + run: | + echo "## 🐳 Docker Release Complete!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published Images" >> $GITHUB_STEP_SUMMARY + echo "- \`unclecode/crawl4ai:${{ steps.get_version.outputs.VERSION }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`unclecode/crawl4ai:${{ steps.versions.outputs.MINOR }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`unclecode/crawl4ai:${{ steps.versions.outputs.MAJOR }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`unclecode/crawl4ai:latest\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Platforms" >> $GITHUB_STEP_SUMMARY + echo "- linux/amd64" >> $GITHUB_STEP_SUMMARY + echo "- linux/arm64" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### πŸš€ Pull Command" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "docker pull unclecode/crawl4ai:${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/docs/ARCHITECTURE.md b/.github/workflows/docs/ARCHITECTURE.md new file mode 100644 index 00000000..aab2e8c1 --- /dev/null +++ b/.github/workflows/docs/ARCHITECTURE.md @@ -0,0 +1,917 @@ +# Workflow Architecture Documentation + +## Overview + +This document describes the technical architecture of the split release pipeline for Crawl4AI. + +--- + +## Architecture Diagram + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Developer β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ git tag v1.2.3 β”‚ +β”‚ git push --tags β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ GitHub Repository β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Tag Event: v1.2.3 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ release.yml (Release Pipeline) β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 1. Extract Version β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ v1.2.3 β†’ 1.2.3 β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 2. Validate Version β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Tag == __version__.py β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 3. Build Python Package β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Source dist (.tar.gz) β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Wheel (.whl) β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 4. Upload to PyPI β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Authenticate with token β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Upload dist/* β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 5. Create GitHub Release β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Tag: v1.2.3 β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Body: Install instructions β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Status: Published β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Release Event: published (v1.2.3) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ docker-release.yml (Docker Pipeline) β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 1. Extract Version from Release β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ github.event.release.tag_name β†’ 1.2.3 β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 2. Parse Semantic Versions β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ 1.2.3 β†’ Major: 1, Minor: 1.2 β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 3. Setup Multi-Arch Build β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Docker Buildx β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - QEMU emulation β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 4. Authenticate Docker Hub β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Username: DOCKER_USERNAME β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Token: DOCKER_TOKEN β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 5. Build Multi-Arch Images β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ linux/amd64 β”‚ linux/arm64 β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Cache: GitHub Actions (type=gha) β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ 6. Push to Docker Hub β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Tags: β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - unclecode/crawl4ai:1.2.3 β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - unclecode/crawl4ai:1.2 β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - unclecode/crawl4ai:1 β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - unclecode/crawl4ai:latest β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ External Services β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ PyPI β”‚ β”‚ Docker Hub β”‚ β”‚ GitHub β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ crawl4ai β”‚ β”‚ unclecode/ β”‚ β”‚ Releases β”‚ β”‚ +β”‚ β”‚ 1.2.3 β”‚ β”‚ crawl4ai β”‚ β”‚ v1.2.3 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Component Details + +### 1. Release Pipeline (release.yml) + +#### Purpose +Fast publication of Python package and GitHub release. + +#### Input +- **Trigger**: Git tag matching `v*` (excluding `test-v*`) +- **Example**: `v1.2.3` + +#### Processing Stages + +##### Stage 1: Version Extraction +```bash +Input: refs/tags/v1.2.3 +Output: VERSION=1.2.3 +``` + +**Implementation**: +```bash +TAG_VERSION=${GITHUB_REF#refs/tags/v} # Remove 'refs/tags/v' prefix +echo "VERSION=$TAG_VERSION" >> $GITHUB_OUTPUT +``` + +##### Stage 2: Version Validation +```bash +Input: TAG_VERSION=1.2.3 +Check: crawl4ai/__version__.py contains __version__ = "1.2.3" +Output: Pass/Fail +``` + +**Implementation**: +```bash +PACKAGE_VERSION=$(python -c "from crawl4ai.__version__ import __version__; print(__version__)") +if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then + exit 1 +fi +``` + +##### Stage 3: Package Build +```bash +Input: Source code + pyproject.toml +Output: dist/crawl4ai-1.2.3.tar.gz + dist/crawl4ai-1.2.3-py3-none-any.whl +``` + +**Implementation**: +```bash +python -m build +# Uses build backend defined in pyproject.toml +``` + +##### Stage 4: PyPI Upload +```bash +Input: dist/*.{tar.gz,whl} +Auth: PYPI_TOKEN +Output: Package published to PyPI +``` + +**Implementation**: +```bash +twine upload dist/* +# Environment: +# TWINE_USERNAME: __token__ +# TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} +``` + +##### Stage 5: GitHub Release Creation +```bash +Input: Tag: v1.2.3 + Body: Markdown content +Output: Published GitHub release +``` + +**Implementation**: +```yaml +uses: softprops/action-gh-release@v2 +with: + tag_name: v1.2.3 + name: Release v1.2.3 + body: | + Installation instructions and changelog + draft: false + prerelease: false +``` + +#### Output +- **PyPI Package**: https://pypi.org/project/crawl4ai/1.2.3/ +- **GitHub Release**: Published release on repository +- **Event**: `release.published` (triggers Docker workflow) + +#### Timeline +``` +0:00 - Tag pushed +0:01 - Checkout + Python setup +0:02 - Version validation +0:03 - Package build +0:04 - PyPI upload starts +0:06 - PyPI upload complete +0:07 - GitHub release created +0:08 - Workflow complete +``` + +--- + +### 2. Docker Release Pipeline (docker-release.yml) + +#### Purpose +Build and publish multi-architecture Docker images. + +#### Inputs + +##### Input 1: Release Event (Automatic) +```yaml +Event: release.published +Data: github.event.release.tag_name = "v1.2.3" +``` + +##### Input 2: Docker Rebuild Tag (Manual) +```yaml +Tag: docker-rebuild-v1.2.3 +``` + +#### Processing Stages + +##### Stage 1: Version Detection +```bash +# From release event: +VERSION = github.event.release.tag_name.strip("v") +# Result: "1.2.3" + +# From rebuild tag: +VERSION = GITHUB_REF.replace("refs/tags/docker-rebuild-v", "") +# Result: "1.2.3" +``` + +##### Stage 2: Semantic Version Parsing +```bash +Input: VERSION=1.2.3 +Output: MAJOR=1 + MINOR=1.2 + PATCH=3 (implicit) +``` + +**Implementation**: +```bash +MAJOR=$(echo $VERSION | cut -d. -f1) # Extract first component +MINOR=$(echo $VERSION | cut -d. -f1-2) # Extract first two components +``` + +##### Stage 3: Multi-Architecture Setup +```yaml +Setup: + - Docker Buildx (multi-platform builder) + - QEMU (ARM emulation on x86) + +Platforms: + - linux/amd64 (x86_64) + - linux/arm64 (aarch64) +``` + +**Architecture**: +``` +GitHub Runner (linux/amd64) + β”œβ”€ Buildx Builder + β”‚ β”œβ”€ Native: Build linux/amd64 image + β”‚ └─ QEMU: Emulate ARM to build linux/arm64 image + └─ Generate manifest list (points to both images) +``` + +##### Stage 4: Docker Hub Authentication +```bash +Input: DOCKER_USERNAME + DOCKER_TOKEN +Output: Authenticated Docker client +``` + +##### Stage 5: Build with Cache +```yaml +Cache Configuration: + cache-from: type=gha # Read from GitHub Actions cache + cache-to: type=gha,mode=max # Write all layers + +Cache Key Components: + - Workflow file path + - Branch name + - Architecture (amd64/arm64) +``` + +**Cache Hierarchy**: +``` +Cache Entry: main/docker-release.yml/linux-amd64 + β”œβ”€ Layer: sha256:abc123... (FROM python:3.12) + β”œβ”€ Layer: sha256:def456... (RUN apt-get update) + β”œβ”€ Layer: sha256:ghi789... (COPY requirements.txt) + β”œβ”€ Layer: sha256:jkl012... (RUN pip install) + └─ Layer: sha256:mno345... (COPY . /app) + +Cache Hit/Miss Logic: + - If layer input unchanged β†’ cache hit β†’ skip build + - If layer input changed β†’ cache miss β†’ rebuild + all subsequent layers +``` + +##### Stage 6: Tag Generation +```bash +Input: VERSION=1.2.3, MAJOR=1, MINOR=1.2 + +Output Tags: + - unclecode/crawl4ai:1.2.3 (exact version) + - unclecode/crawl4ai:1.2 (minor version) + - unclecode/crawl4ai:1 (major version) + - unclecode/crawl4ai:latest (latest stable) +``` + +**Tag Strategy**: +- All tags point to same image SHA +- Users can pin to desired stability level +- Pushing new version updates `1`, `1.2`, and `latest` automatically + +##### Stage 7: Push to Registry +```bash +For each tag: + For each platform (amd64, arm64): + Push image to Docker Hub + +Create manifest list: + Manifest: unclecode/crawl4ai:1.2.3 + β”œβ”€ linux/amd64: sha256:abc... + └─ linux/arm64: sha256:def... + +Docker CLI automatically selects correct platform on pull +``` + +#### Output +- **Docker Images**: 4 tags Γ— 2 platforms = 8 image variants + 4 manifests +- **Docker Hub**: https://hub.docker.com/r/unclecode/crawl4ai/tags + +#### Timeline + +**Cold Cache (First Build)**: +``` +0:00 - Release event received +0:01 - Checkout + Buildx setup +0:02 - Docker Hub auth +0:03 - Start build (amd64) +0:08 - Complete amd64 build +0:09 - Start build (arm64) +0:14 - Complete arm64 build +0:15 - Generate manifests +0:16 - Push all tags +0:17 - Workflow complete +``` + +**Warm Cache (Code Change Only)**: +``` +0:00 - Release event received +0:01 - Checkout + Buildx setup +0:02 - Docker Hub auth +0:03 - Start build (amd64) - cache hit for layers 1-4 +0:04 - Complete amd64 build (only layer 5 rebuilt) +0:05 - Start build (arm64) - cache hit for layers 1-4 +0:06 - Complete arm64 build (only layer 5 rebuilt) +0:07 - Generate manifests +0:08 - Push all tags +0:09 - Workflow complete +``` + +--- + +## Data Flow + +### Version Information Flow + +``` +Developer + β”‚ + β–Ό +crawl4ai/__version__.py + __version__ = "1.2.3" + β”‚ + β”œβ”€β–Ί Git Tag + β”‚ v1.2.3 + β”‚ β”‚ + β”‚ β–Ό + β”‚ release.yml + β”‚ β”‚ + β”‚ β”œβ”€β–Ί Validation + β”‚ β”‚ βœ“ Match + β”‚ β”‚ + β”‚ β”œβ”€β–Ί PyPI Package + β”‚ β”‚ crawl4ai==1.2.3 + β”‚ β”‚ + β”‚ └─► GitHub Release + β”‚ v1.2.3 + β”‚ β”‚ + β”‚ β–Ό + β”‚ docker-release.yml + β”‚ β”‚ + β”‚ └─► Docker Tags + β”‚ 1.2.3, 1.2, 1, latest + β”‚ + └─► Package Metadata + pyproject.toml + version = "1.2.3" +``` + +### Secrets Flow + +``` +GitHub Secrets (Encrypted at Rest) + β”‚ + β”œβ”€β–Ί PYPI_TOKEN + β”‚ β”‚ + β”‚ β–Ό + β”‚ release.yml + β”‚ β”‚ + β”‚ β–Ό + β”‚ TWINE_PASSWORD env var (masked in logs) + β”‚ β”‚ + β”‚ β–Ό + β”‚ PyPI API (HTTPS) + β”‚ + β”œβ”€β–Ί DOCKER_USERNAME + β”‚ β”‚ + β”‚ β–Ό + β”‚ docker-release.yml + β”‚ β”‚ + β”‚ β–Ό + β”‚ docker/login-action (masked in logs) + β”‚ β”‚ + β”‚ β–Ό + β”‚ Docker Hub API (HTTPS) + β”‚ + └─► DOCKER_TOKEN + β”‚ + β–Ό + docker-release.yml + β”‚ + β–Ό + docker/login-action (masked in logs) + β”‚ + β–Ό + Docker Hub API (HTTPS) +``` + +### Artifact Flow + +``` +Source Code + β”‚ + β”œβ”€β–Ί release.yml + β”‚ β”‚ + β”‚ β–Ό + β”‚ python -m build + β”‚ β”‚ + β”‚ β”œβ”€β–Ί crawl4ai-1.2.3.tar.gz + β”‚ β”‚ β”‚ + β”‚ β”‚ β–Ό + β”‚ β”‚ PyPI Storage + β”‚ β”‚ β”‚ + β”‚ β”‚ β–Ό + β”‚ β”‚ pip install crawl4ai + β”‚ β”‚ + β”‚ └─► crawl4ai-1.2.3-py3-none-any.whl + β”‚ β”‚ + β”‚ β–Ό + β”‚ PyPI Storage + β”‚ β”‚ + β”‚ β–Ό + β”‚ pip install crawl4ai + β”‚ + └─► docker-release.yml + β”‚ + β–Ό + docker build + β”‚ + β”œβ”€β–Ί Image: linux/amd64 + β”‚ β”‚ + β”‚ └─► Docker Hub + β”‚ unclecode/crawl4ai:1.2.3-amd64 + β”‚ + └─► Image: linux/arm64 + β”‚ + └─► Docker Hub + unclecode/crawl4ai:1.2.3-arm64 +``` + +--- + +## State Machines + +### Release Pipeline State Machine + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ START β”‚ +β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Extract β”‚ +β”‚ Version β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Validate │─────►│ FAILED β”‚ +β”‚ Version β”‚ No β”‚ (Exit 1)β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Yes + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Build β”‚ +β”‚ Package β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Upload │─────►│ FAILED β”‚ +β”‚ to PyPI β”‚ Errorβ”‚ (Exit 1)β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Success + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Create β”‚ +β”‚ GH Release β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SUCCESS β”‚ +β”‚ (Emit Event) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Docker Pipeline State Machine + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ START β”‚ +β”‚ (Event) β”‚ +β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Detect β”‚ +β”‚ Version β”‚ +β”‚ Source β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Parse β”‚ +β”‚ Semantic β”‚ +β”‚ Versions β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Authenticate │─────►│ FAILED β”‚ +β”‚ Docker Hub β”‚ Errorβ”‚ (Exit 1)β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Success + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Build β”‚ +β”‚ amd64 β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Build │─────►│ FAILED β”‚ +β”‚ arm64 β”‚ Errorβ”‚ (Exit 1)β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Success + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Push All β”‚ +β”‚ Tags β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SUCCESS β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Security Architecture + +### Threat Model + +#### Threats Mitigated + +1. **Secret Exposure** + - Mitigation: GitHub Actions secret masking + - Evidence: Secrets never appear in logs + +2. **Unauthorized Package Upload** + - Mitigation: Scoped PyPI tokens + - Evidence: Token limited to `crawl4ai` project + +3. **Man-in-the-Middle** + - Mitigation: HTTPS for all API calls + - Evidence: PyPI, Docker Hub, GitHub all use TLS + +4. **Supply Chain Tampering** + - Mitigation: Immutable artifacts, content checksums + - Evidence: PyPI stores SHA256, Docker uses content-addressable storage + +#### Trust Boundaries + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Trusted Zone β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ GitHub Actions Runner β”‚ β”‚ +β”‚ β”‚ - Ephemeral VM β”‚ β”‚ +β”‚ β”‚ - Isolated environment β”‚ β”‚ +β”‚ β”‚ - Access to secrets β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”‚ HTTPS (TLS 1.2+) β”‚ +β”‚ β–Ό β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ PyPI β”‚ β”‚ Docker β”‚ β”‚ GitHub β”‚ +β”‚ API β”‚ β”‚ Hub β”‚ β”‚ API β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + External External External + Service Service Service +``` + +### Secret Management + +#### Secret Lifecycle + +``` +Creation (Developer) + β”‚ + β”œβ”€β–Ί PyPI: Create API token (scoped to project) + β”œβ”€β–Ί Docker Hub: Create access token (read/write) + β”‚ + β–Ό +Storage (GitHub) + β”‚ + β”œβ”€β–Ί Encrypted at rest (AES-256) + β”œβ”€β–Ί Access controlled (repo-scoped) + β”‚ + β–Ό +Usage (Workflow) + β”‚ + β”œβ”€β–Ί Injected as env vars + β”œβ”€β–Ί Masked in logs (GitHub redacts on output) + β”œβ”€β–Ί Never persisted to disk (in-memory only) + β”‚ + β–Ό +Transmission (API Call) + β”‚ + β”œβ”€β–Ί HTTPS only + β”œβ”€β–Ί TLS 1.2+ with strong ciphers + β”‚ + β–Ό +Rotation (Manual) + β”‚ + └─► Regenerate on PyPI/Docker Hub + Update GitHub secret +``` + +--- + +## Performance Characteristics + +### Release Pipeline Performance + +| Metric | Value | Notes | +|--------|-------|-------| +| Cold start | ~2-3 min | First run on new runner | +| Warm start | ~2-3 min | Minimal caching benefit | +| PyPI upload | ~30-60 sec | Network-bound | +| Package build | ~30 sec | CPU-bound | +| Parallelization | None | Sequential by design | + +### Docker Pipeline Performance + +| Metric | Cold Cache | Warm Cache (code) | Warm Cache (deps) | +|--------|-----------|-------------------|-------------------| +| Total time | 10-15 min | 1-2 min | 3-5 min | +| amd64 build | 5-7 min | 30-60 sec | 1-2 min | +| arm64 build | 5-7 min | 30-60 sec | 1-2 min | +| Push time | 1-2 min | 30 sec | 30 sec | +| Cache hit rate | 0% | 85% | 60% | + +### Cache Performance Model + +```python +def estimate_build_time(changes): + base_time = 60 # seconds (setup + push) + + if "Dockerfile" in changes: + return base_time + (10 * 60) # Full rebuild: ~11 min + elif "requirements.txt" in changes: + return base_time + (3 * 60) # Deps rebuild: ~4 min + elif any(f.endswith(".py") for f in changes): + return base_time + 60 # Code only: ~2 min + else: + return base_time # No changes: ~1 min +``` + +--- + +## Scalability Considerations + +### Current Limits + +| Resource | Limit | Impact | +|----------|-------|--------| +| Workflow concurrency | 20 (default) | Max 20 releases in parallel | +| Artifact storage | 500 MB/artifact | PyPI packages small (<10 MB) | +| Cache storage | 10 GB/repo | Docker layers fit comfortably | +| Workflow run time | 6 hours | Plenty of headroom | + +### Scaling Strategies + +#### Horizontal Scaling (Multiple Repos) +``` +crawl4ai (main) + β”œβ”€ release.yml + └─ docker-release.yml + +crawl4ai-plugins (separate) + β”œβ”€ release.yml + └─ docker-release.yml + +Each repo has independent: + - Secrets + - Cache (10 GB each) + - Concurrency limits (20 each) +``` + +#### Vertical Scaling (Larger Runners) +```yaml +jobs: + docker: + runs-on: ubuntu-latest-8-cores # GitHub-hosted larger runner + # 4x faster builds for CPU-bound layers +``` + +--- + +## Disaster Recovery + +### Failure Scenarios + +#### Scenario 1: Release Pipeline Fails + +**Failure Point**: PyPI upload fails (network error) + +**State**: +- βœ“ Version validated +- βœ“ Package built +- βœ— PyPI upload +- βœ— GitHub release + +**Recovery**: +```bash +# Manual upload +twine upload dist/* + +# Retry workflow (re-run from GitHub Actions UI) +``` + +**Prevention**: Add retry logic to PyPI upload + +#### Scenario 2: Docker Pipeline Fails + +**Failure Point**: ARM build fails (dependency issue) + +**State**: +- βœ“ PyPI published +- βœ“ GitHub release created +- βœ“ amd64 image built +- βœ— arm64 image build + +**Recovery**: +```bash +# Fix Dockerfile +git commit -am "fix: ARM build dependency" + +# Trigger rebuild +git tag docker-rebuild-v1.2.3 +git push origin docker-rebuild-v1.2.3 +``` + +**Impact**: PyPI package available, only Docker ARM users affected + +#### Scenario 3: Partial Release + +**Failure Point**: GitHub release creation fails + +**State**: +- βœ“ PyPI published +- βœ— GitHub release +- βœ— Docker images + +**Recovery**: +```bash +# Create release manually +gh release create v1.2.3 \ + --title "Release v1.2.3" \ + --notes "..." + +# This triggers docker-release.yml automatically +``` + +--- + +## Monitoring and Observability + +### Metrics to Track + +#### Release Pipeline +- Success rate (target: >99%) +- Duration (target: <3 min) +- PyPI upload time (target: <60 sec) + +#### Docker Pipeline +- Success rate (target: >95%) +- Duration (target: <15 min cold, <2 min warm) +- Cache hit rate (target: >80% for code changes) + +### Alerting + +**Critical Alerts**: +- Release pipeline failure (blocks release) +- PyPI authentication failure (expired token) + +**Warning Alerts**: +- Docker build >15 min (performance degradation) +- Cache hit rate <50% (cache issue) + +### Logging + +**GitHub Actions Logs**: +- Retention: 90 days +- Downloadable: Yes +- Searchable: Limited + +**Recommended External Logging**: +```yaml +- name: Send logs to external service + if: failure() + run: | + curl -X POST https://logs.example.com/api/v1/logs \ + -H "Content-Type: application/json" \ + -d "{\"workflow\": \"${{ github.workflow }}\", \"status\": \"failed\"}" +``` + +--- + +## Future Enhancements + +### Planned Improvements + +1. **Automated Changelog Generation** + - Use conventional commits + - Generate CHANGELOG.md automatically + +2. **Pre-release Testing** + - Test builds on `test-v*` tags + - Upload to TestPyPI + +3. **Notification System** + - Slack/Discord notifications on release + - Email on failure + +4. **Performance Optimization** + - Parallel Docker builds (amd64 + arm64 simultaneously) + - Persistent runners for better caching + +5. **Enhanced Validation** + - Smoke tests after PyPI upload + - Container security scanning + +--- + +## References + +- [GitHub Actions Architecture](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions) +- [Docker Build Cache](https://docs.docker.com/build/cache/) +- [PyPI API Documentation](https://warehouse.pypa.io/api-reference/) + +--- + +**Last Updated**: 2025-01-21 +**Version**: 2.0 diff --git a/.github/workflows/docs/README.md b/.github/workflows/docs/README.md new file mode 100644 index 00000000..e96a4c5e --- /dev/null +++ b/.github/workflows/docs/README.md @@ -0,0 +1,1029 @@ +# GitHub Actions Workflows Documentation + +## Table of Contents + +1. [Overview](#overview) +2. [Workflow Architecture](#workflow-architecture) +3. [Workflows](#workflows) + - [Release Pipeline](#release-pipeline) + - [Docker Release](#docker-release) +4. [Usage Guide](#usage-guide) +5. [Secrets Configuration](#secrets-configuration) +6. [Troubleshooting](#troubleshooting) +7. [Advanced Topics](#advanced-topics) + +--- + +## Overview + +This repository uses a **split release pipeline** architecture to optimize release times and provide flexibility. The release process is divided into two independent workflows: + +1. **Release Pipeline** (`release.yml`) - Fast PyPI and GitHub release publication +2. **Docker Release** (`docker-release.yml`) - Multi-architecture Docker image builds with caching + +### Why Split Workflows? + +**Problem**: Docker multi-architecture builds take 10-15 minutes, blocking quick package releases. + +**Solution**: Separate Docker builds into an independent workflow that runs in parallel. + +**Benefits**: +- βœ… PyPI package available in ~2-3 minutes +- βœ… GitHub release published immediately +- βœ… Docker images build in parallel (non-blocking) +- βœ… Can rebuild Docker images independently +- βœ… Faster subsequent builds with layer caching + +--- + +## Workflow Architecture + +``` +Tag Push (v1.2.3) + β”‚ + β”œβ”€β–Ί Release Pipeline (release.yml) + β”‚ β”œβ”€ Version validation + β”‚ β”œβ”€ Build Python package + β”‚ β”œβ”€ Upload to PyPI βœ“ + β”‚ └─ Create GitHub Release βœ“ + β”‚ β”‚ + β”‚ └─► Triggers Docker Release (docker-release.yml) + β”‚ β”œβ”€ Build multi-arch images + β”‚ β”œβ”€ Use GitHub Actions cache + β”‚ └─ Push to Docker Hub βœ“ + β”‚ + └─► Total Time: + - PyPI/GitHub: 2-3 minutes + - Docker: 1-15 minutes (parallel) +``` + +### Event Flow + +```mermaid +graph TD + A[Push tag v1.2.3] --> B[release.yml triggered] + B --> C{Version Check} + C -->|Match| D[Build Package] + C -->|Mismatch| E[❌ Fail - Update __version__.py] + D --> F[Upload to PyPI] + F --> G[Create GitHub Release] + G --> H[docker-release.yml triggered] + H --> I[Build Docker Images] + I --> J[Push to Docker Hub] + + K[Push tag docker-rebuild-v1.2.3] --> H +``` + +--- + +## Workflows + +### Release Pipeline + +**File**: `.github/workflows/release.yml` + +#### Trigger + +```yaml +on: + push: + tags: + - 'v*' # Matches: v1.2.3, v2.0.0, etc. + - '!test-v*' # Excludes: test-v1.2.3 +``` + +#### Jobs & Steps + +##### 1. Version Extraction +```bash +# Extracts version from tag +v1.2.3 β†’ 1.2.3 +``` + +##### 2. Version Consistency Check +Validates that the git tag matches `crawl4ai/__version__.py`: + +```python +# crawl4ai/__version__.py must contain: +__version__ = "1.2.3" # Must match tag v1.2.3 +``` + +**Failure Example**: +``` +Tag version: 1.2.3 +Package version: 1.2.2 +❌ Version mismatch! Please update crawl4ai/__version__.py +``` + +##### 3. Package Build +- Installs build dependencies (`build`, `twine`) +- Builds source distribution and wheel: `python -m build` +- Validates package: `twine check dist/*` + +##### 4. PyPI Upload +```bash +twine upload dist/* +# Uploads to: https://pypi.org/project/crawl4ai/ +``` + +**Environment Variables**: +- `TWINE_USERNAME`: `__token__` (PyPI API token authentication) +- `TWINE_PASSWORD`: `${{ secrets.PYPI_TOKEN }}` + +##### 5. GitHub Release Creation +Creates a release with: +- Tag: `v1.2.3` +- Title: `Release v1.2.3` +- Body: Installation instructions + changelog link +- Status: Published (not draft) + +**Note**: The release body includes a link to the Docker workflow status, informing users that Docker images are building. + +##### 6. Summary Report +Generates a GitHub Actions summary with: +- PyPI package URL and version +- GitHub release URL +- Link to Docker workflow status + +#### Output Artifacts + +| Artifact | Location | Time | +|----------|----------|------| +| PyPI Package | https://pypi.org/project/crawl4ai/ | ~2-3 min | +| GitHub Release | Repository releases page | ~2-3 min | + +--- + +### Docker Release + +**File**: `.github/workflows/docker-release.yml` + +#### Triggers + +This workflow has **two independent triggers**: + +##### 1. Automatic Trigger (Release Event) +```yaml +on: + release: + types: [published] +``` + +Triggers when `release.yml` publishes a GitHub release. + +##### 2. Manual Trigger (Docker Rebuild Tag) +```yaml +on: + push: + tags: + - 'docker-rebuild-v*' +``` + +Allows rebuilding Docker images without creating a new release. + +**Use case**: Fix Dockerfile, rebuild images for existing version. + +#### Jobs & Steps + +##### 1. Version Detection +Intelligently detects version from either trigger: + +```bash +# From release event: +github.event.release.tag_name β†’ v1.2.3 β†’ 1.2.3 + +# From docker-rebuild tag: +docker-rebuild-v1.2.3 β†’ 1.2.3 +``` + +##### 2. Semantic Version Extraction +```bash +VERSION=1.2.3 +MAJOR=1 # First component +MINOR=1.2 # First two components +``` + +Used for Docker tag variations. + +##### 3. Docker Buildx Setup +Configures multi-architecture build support: +- Platform: linux/amd64, linux/arm64 +- Builder: Buildx with QEMU emulation + +##### 4. Docker Hub Authentication +```yaml +username: ${{ secrets.DOCKER_USERNAME }} +password: ${{ secrets.DOCKER_TOKEN }} +``` + +##### 5. Multi-Architecture Build & Push + +**Docker Tags Created**: +``` +unclecode/crawl4ai:1.2.3 # Exact version +unclecode/crawl4ai:1.2 # Minor version +unclecode/crawl4ai:1 # Major version +unclecode/crawl4ai:latest # Latest stable +``` + +**Platforms**: +- `linux/amd64` (x86_64 - Intel/AMD processors) +- `linux/arm64` (ARM processors - Apple Silicon, AWS Graviton) + +**Caching Configuration**: +```yaml +cache-from: type=gha # Read from GitHub Actions cache +cache-to: type=gha,mode=max # Write all layers to cache +``` + +##### 6. Summary Report +Generates a summary with: +- Published image tags +- Supported platforms +- Pull command example + +#### Docker Layer Caching + +**How It Works**: + +Docker builds images in layers: +```dockerfile +FROM python:3.12 # Layer 1 (base image) +RUN apt-get update # Layer 2 (system packages) +COPY requirements.txt . # Layer 3 (dependency file) +RUN pip install -r ... # Layer 4 (Python packages) +COPY . . # Layer 5 (application code) +``` + +**Cache Behavior**: + +| Change Type | Cached Layers | Rebuild Time | +|-------------|---------------|--------------| +| No changes | 1-5 | ~30-60 sec | +| Code only | 1-4 | ~1-2 min | +| Dependencies | 1-3 | ~3-5 min | +| Dockerfile | None | ~10-15 min | + +**Cache Storage**: +- Location: GitHub Actions cache +- Limit: 10GB per repository +- Retention: 7 days for unused cache +- Cleanup: Automatic (LRU eviction) + +**Cache Efficiency Example**: + +```bash +# First build (v1.0.0) +Build time: 12m 34s +Cache: 0% (cold start) + +# Second build (v1.0.1 - code change only) +Build time: 1m 47s +Cache: 85% hit rate +Cached: Base image, system packages, Python dependencies + +# Third build (v1.0.2 - dependency update) +Build time: 4m 12s +Cache: 60% hit rate +Cached: Base image, system packages +``` + +#### Output Artifacts + +| Artifact | Location | Tags | Time | +|----------|----------|------|------| +| Docker Images | Docker Hub | 4 tags | 1-15 min | + +**Docker Hub URL**: https://hub.docker.com/r/unclecode/crawl4ai + +--- + +## Usage Guide + +### Standard Release Process + +#### Step 1: Update Version + +Edit `crawl4ai/__version__.py`: +```python +__version__ = "1.2.3" +``` + +#### Step 2: Commit and Tag + +```bash +git add crawl4ai/__version__.py +git commit -m "chore: bump version to 1.2.3" +git tag v1.2.3 +git push origin main +git push origin v1.2.3 +``` + +#### Step 3: Monitor Workflows + +**Release Pipeline** (~2-3 minutes): +``` +βœ“ Version check passed +βœ“ Package built +βœ“ Uploaded to PyPI +βœ“ GitHub release created +``` + +**Docker Release** (~1-15 minutes, runs in parallel): +``` +βœ“ Images built for amd64, arm64 +βœ“ Pushed 4 tags to Docker Hub +βœ“ Cache updated +``` + +#### Step 4: Verify Deployment + +```bash +# Check PyPI +pip install crawl4ai==1.2.3 + +# Check Docker +docker pull unclecode/crawl4ai:1.2.3 +docker run unclecode/crawl4ai:1.2.3 --version +``` + +### Manual Docker Rebuild + +**When to Use**: +- Dockerfile fixed after release +- Security patch in base image +- Rebuild needed without new version + +**Process**: + +```bash +# Rebuild Docker images for existing version 1.2.3 +git tag docker-rebuild-v1.2.3 +git push origin docker-rebuild-v1.2.3 +``` + +This triggers **only** `docker-release.yml`, not `release.yml`. + +**Result**: +- Docker images rebuilt with same version tag +- PyPI package unchanged +- GitHub release unchanged + +### Rollback Procedure + +#### Rollback PyPI Package +PyPI does not allow re-uploading the same version. Instead: + +```bash +# Publish a patch version +git tag v1.2.4 +git push origin v1.2.4 +``` + +Then update documentation to recommend the new version. + +#### Rollback Docker Images + +```bash +# Option 1: Rebuild with fixed code +git tag docker-rebuild-v1.2.3 +git push origin docker-rebuild-v1.2.3 + +# Option 2: Manually retag in Docker Hub (advanced) +# Not recommended - use git tags for traceability +``` + +--- + +## Secrets Configuration + +### Required Secrets + +Configure these in: **Repository Settings β†’ Secrets and variables β†’ Actions** + +#### 1. PYPI_TOKEN + +**Purpose**: Authenticate with PyPI for package uploads + +**How to Create**: +1. Go to https://pypi.org/manage/account/token/ +2. Create token with scope: "Entire account" or "Project: crawl4ai" +3. Copy token (starts with `pypi-`) +4. Add to GitHub secrets as `PYPI_TOKEN` + +**Format**: +``` +pypi-AgEIcHlwaS5vcmcCJGQ4M2Y5YTM5LWRjMzUtNGY3MS04ZmMwLWVhNzA5MjkzMjk5YQACKl... +``` + +#### 2. DOCKER_USERNAME + +**Purpose**: Docker Hub username for authentication + +**Value**: Your Docker Hub username (e.g., `unclecode`) + +#### 3. DOCKER_TOKEN + +**Purpose**: Docker Hub access token for authentication + +**How to Create**: +1. Go to https://hub.docker.com/settings/security +2. Click "New Access Token" +3. Name: `github-actions-crawl4ai` +4. Permissions: Read, Write, Delete +5. Copy token +6. Add to GitHub secrets as `DOCKER_TOKEN` + +**Format**: +``` +dckr_pat_1a2b3c4d5e6f7g8h9i0j +``` + +### Built-in Secrets + +#### GITHUB_TOKEN + +**Purpose**: Create GitHub releases + +**Note**: Automatically provided by GitHub Actions. No configuration needed. + +**Permissions**: Configured in workflow file: +```yaml +permissions: + contents: write # Required for creating releases +``` + +--- + +## Troubleshooting + +### Version Mismatch Error + +**Error**: +``` +❌ Version mismatch! Tag: 1.2.3, Package: 1.2.2 +Please update crawl4ai/__version__.py to match the tag version +``` + +**Cause**: Git tag doesn't match `__version__` in `crawl4ai/__version__.py` + +**Fix**: +```bash +# Option 1: Update __version__.py and re-tag +vim crawl4ai/__version__.py # Change to 1.2.3 +git add crawl4ai/__version__.py +git commit -m "fix: update version to 1.2.3" +git tag -d v1.2.3 # Delete local tag +git push --delete origin v1.2.3 # Delete remote tag +git tag v1.2.3 # Create new tag +git push origin main +git push origin v1.2.3 + +# Option 2: Use correct tag +git tag v1.2.2 # Match existing __version__ +git push origin v1.2.2 +``` + +### PyPI Upload Failure + +**Error**: +``` +HTTPError: 403 Forbidden +``` + +**Causes & Fixes**: + +1. **Invalid Token**: + - Verify `PYPI_TOKEN` in GitHub secrets + - Ensure token hasn't expired + - Regenerate token on PyPI + +2. **Version Already Exists**: + ``` + HTTPError: 400 File already exists + ``` + - PyPI doesn't allow re-uploading same version + - Increment version number and retry + +3. **Package Name Conflict**: + - Ensure you own the `crawl4ai` package on PyPI + - Check token scope includes this project + +### Docker Build Failure + +**Error**: +``` +failed to solve: process "/bin/sh -c ..." did not complete successfully +``` + +**Debug Steps**: + +1. **Check Build Logs**: + - Go to Actions tab β†’ Docker Release workflow + - Expand "Build and push Docker images" step + - Look for specific error + +2. **Test Locally**: + ```bash + docker build -t crawl4ai:test . + ``` + +3. **Common Issues**: + + **Dependency installation fails**: + ```dockerfile + # Check requirements.txt is valid + # Ensure all packages are available + ``` + + **Architecture-specific issues**: + ```bash + # Test both platforms locally (if on Mac with Apple Silicon) + docker buildx build --platform linux/amd64,linux/arm64 -t test . + ``` + +4. **Cache Issues**: + ```bash + # Clear cache by pushing a tag with different content + # Or wait 7 days for automatic cache eviction + ``` + +### Docker Authentication Failure + +**Error**: +``` +Error: Cannot perform an interactive login from a non TTY device +``` + +**Cause**: Docker Hub credentials invalid + +**Fix**: +1. Verify `DOCKER_USERNAME` is correct +2. Regenerate `DOCKER_TOKEN` on Docker Hub +3. Update secret in GitHub + +### Docker Release Not Triggering + +**Issue**: Pushed tag `v1.2.3`, but `docker-release.yml` didn't run + +**Causes**: + +1. **Release Not Published**: + - Check if `release.yml` completed successfully + - Verify GitHub release is published (not draft) + +2. **Workflow File Syntax Error**: + ```bash + # Validate YAML syntax + yamllint .github/workflows/docker-release.yml + ``` + +3. **Workflow Not on Default Branch**: + - Workflow files must be on `main` branch + - Check if `.github/workflows/docker-release.yml` exists on `main` + +**Debug**: +```bash +# Check workflow files +git ls-tree main .github/workflows/ + +# Check GitHub Actions tab for workflow runs +``` + +### Cache Not Working + +**Issue**: Every build takes 10-15 minutes despite using cache + +**Causes**: + +1. **Cache Scope**: + - Cache is per-branch and per-workflow + - First build on new branch is always cold + +2. **Dockerfile Changes**: + - Any change invalidates subsequent layers + - Optimize Dockerfile layer order (stable β†’ volatile) + +3. **Base Image Updates**: + - `FROM python:3.12` pulls latest monthly + - Pin to specific digest for stable cache + +**Optimization**: +```dockerfile +# Good: Stable layers first +FROM python:3.12 +RUN apt-get update && apt-get install -y ... +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY . . + +# Bad: Volatile layers first (breaks cache often) +FROM python:3.12 +COPY . . +RUN pip install -r requirements.txt +``` + +--- + +## Advanced Topics + +### Multi-Architecture Build Details + +#### Platform Support + +| Platform | Architecture | Use Cases | +|----------|-------------|-----------| +| linux/amd64 | x86_64 | AWS EC2, GCP, Azure, Traditional servers | +| linux/arm64 | aarch64 | Apple Silicon, AWS Graviton, Raspberry Pi | + +#### Build Process + +```bash +# Buildx uses QEMU to emulate different architectures +docker buildx create --use # Create builder +docker buildx build --platform linux/amd64,linux/arm64 ... +``` + +**Under the Hood**: +1. For each platform: + - Spawn QEMU emulator + - Execute Dockerfile instructions + - Generate platform-specific image +2. Create manifest list (multi-arch index) +3. Push all variants + manifest to registry + +**Pull Behavior**: +```bash +# Docker automatically selects correct platform +docker pull unclecode/crawl4ai:latest + +# On M1 Mac: Pulls arm64 variant +# On Intel Linux: Pulls amd64 variant + +# Force specific platform +docker pull --platform linux/amd64 unclecode/crawl4ai:latest +``` + +### Semantic Versioning Strategy + +#### Tag Scheme + +``` +v1.2.3 + β”‚ β”‚ β”‚ + β”‚ β”‚ └─ Patch: Bug fixes, no API changes + β”‚ └─── Minor: New features, backward compatible + └───── Major: Breaking changes +``` + +#### Docker Tag Mapping + +| Git Tag | Docker Tags Created | Use Case | +|---------|---------------------|----------| +| v1.2.3 | 1.2.3, 1.2, 1, latest | Full version chain | +| v2.0.0 | 2.0.0, 2.0, 2, latest | Major version bump | + +**Example Evolution**: + +```bash +# Release v1.0.0 +Tags: 1.0.0, 1.0, 1, latest + +# Release v1.1.0 +Tags: 1.1.0, 1.1, 1, latest +# Note: 1.0 still exists, but 1 and latest now point to 1.1.0 + +# Release v1.2.0 +Tags: 1.2.0, 1.2, 1, latest +# Note: 1.0 and 1.1 still exist, but 1 and latest now point to 1.2.0 + +# Release v2.0.0 +Tags: 2.0.0, 2.0, 2, latest +# Note: All v1.x tags still exist, but latest now points to 2.0.0 +``` + +**User Pinning Strategies**: + +```bash +# Maximum stability (never updates) +docker pull unclecode/crawl4ai:1.2.3 + +# Get patch updates only +docker pull unclecode/crawl4ai:1.2 + +# Get minor updates (features, bug fixes) +docker pull unclecode/crawl4ai:1 + +# Always get latest (potentially breaking) +docker pull unclecode/crawl4ai:latest +``` + +### Cache Optimization Strategies + +#### 1. Layer Order Optimization + +```dockerfile +# BEFORE (cache breaks often) +FROM python:3.12 +COPY . /app # Changes every commit +RUN pip install -r requirements.txt +RUN apt-get install -y ffmpeg + +# AFTER (cache-optimized) +FROM python:3.12 +RUN apt-get update && apt-get install -y ffmpeg # Rarely changes +COPY requirements.txt /app/requirements.txt # Changes occasionally +RUN pip install -r /app/requirements.txt +COPY . /app # Changes every commit +``` + +#### 2. Multi-Stage Builds + +```dockerfile +# Build stage (cached separately) +FROM python:3.12 as builder +COPY requirements.txt . +RUN pip install --user -r requirements.txt + +# Runtime stage +FROM python:3.12-slim +COPY --from=builder /root/.local /root/.local +COPY . /app +ENV PATH=/root/.local/bin:$PATH +``` + +**Benefits**: +- Builder stage cached independently +- Runtime image smaller +- Faster rebuilds + +#### 3. Dependency Caching + +```dockerfile +# Cache pip packages +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements.txt + +# Cache apt packages +RUN --mount=type=cache,target=/var/cache/apt \ + apt-get update && apt-get install -y ... +``` + +**Note**: Requires BuildKit (enabled by default in GitHub Actions) + +#### 4. Base Image Pinning + +```dockerfile +# VOLATILE (updates monthly, breaks cache) +FROM python:3.12 + +# STABLE (fixed digest, cache preserved) +FROM python:3.12@sha256:8c5e5c77e7b9e44a6f0e3b9e8f5e5c77e7b9e44a6f0e3b9e8f5e5c77e7b9e44a +``` + +Find digest: +```bash +docker pull python:3.12 +docker inspect python:3.12 | grep -A 2 RepoDigests +``` + +### Workflow Security Best Practices + +#### 1. Secret Handling + +**Never**: +```yaml +# DON'T: Hardcode secrets +run: echo "my-secret-token" | docker login + +# DON'T: Log secrets +run: echo "Token is ${{ secrets.PYPI_TOKEN }}" +``` + +**Always**: +```yaml +# DO: Use environment variables +env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} +run: twine upload dist/* + +# DO: Use action inputs (masked automatically) +uses: docker/login-action@v3 +with: + password: ${{ secrets.DOCKER_TOKEN }} +``` + +#### 2. Permission Minimization + +```yaml +# Specific permissions only +permissions: + contents: write # Only what's needed + # NOT: permissions: write-all +``` + +#### 3. Dependency Pinning + +```yaml +# DON'T: Use floating versions +uses: actions/checkout@v4 + +# DO: Pin to SHA (immutable) +uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 +``` + +#### 4. Token Scoping + +**PyPI Token**: +- Scope: Project-specific (`crawl4ai` only) +- Not: Account-wide access + +**Docker Token**: +- Permissions: Read, Write (not Delete unless needed) +- Expiration: Set to 1 year, rotate regularly + +### Monitoring and Observability + +#### GitHub Actions Metrics + +**Available in Actions tab**: +- Workflow run duration +- Success/failure rates +- Cache hit rates +- Artifact sizes + +#### Custom Metrics + +Add to workflow summary: +```yaml +- name: Build Metrics + run: | + echo "## Build Metrics" >> $GITHUB_STEP_SUMMARY + echo "- Duration: $(date -u -d @$SECONDS +%T)" >> $GITHUB_STEP_SUMMARY + echo "- Cache hit rate: 85%" >> $GITHUB_STEP_SUMMARY +``` + +#### External Monitoring + +**Webhooks**: Configure in Settings β†’ Webhooks +```json +{ + "events": ["workflow_run"], + "url": "https://your-monitoring-service.com/webhook" +} +``` + +**Status Badges**: +```markdown +[![Release](https://github.com/user/repo/actions/workflows/release.yml/badge.svg)](https://github.com/user/repo/actions/workflows/release.yml) + +[![Docker](https://github.com/user/repo/actions/workflows/docker-release.yml/badge.svg)](https://github.com/user/repo/actions/workflows/docker-release.yml) +``` + +### Disaster Recovery + +#### Backup Workflow Files + +**Current Backup**: +- `.github/workflows/release.yml.backup` + +**Recommended**: +```bash +# Automatic backup before modifications +cp .github/workflows/release.yml .github/workflows/release.yml.backup-$(date +%Y%m%d) +git add .github/workflows/*.backup* +git commit -m "backup: workflow before modification" +``` + +#### Recovery from Failed Release + +**Scenario**: v1.2.3 release failed mid-way + +**Steps**: +1. **Identify what succeeded**: + - Check PyPI: `pip search crawl4ai` + - Check Docker Hub: https://hub.docker.com/r/unclecode/crawl4ai/tags + - Check GitHub Releases + +2. **Clean up partial release**: + ```bash + # Delete tag + git tag -d v1.2.3 + git push --delete origin v1.2.3 + + # Delete GitHub release (if created) + gh release delete v1.2.3 + ``` + +3. **Fix issue and retry**: + ```bash + # Fix the issue + # Re-tag and push + git tag v1.2.3 + git push origin v1.2.3 + ``` + +**Note**: Cannot delete PyPI uploads. If PyPI succeeded, increment to v1.2.4. + +### CI/CD Best Practices + +#### 1. Version Validation + +Add pre-commit hook: +```bash +# .git/hooks/pre-commit +#!/bin/bash +VERSION_FILE="crawl4ai/__version__.py" +VERSION=$(python -c "exec(open('$VERSION_FILE').read()); print(__version__)") +echo "Current version: $VERSION" +``` + +#### 2. Changelog Automation + +Use conventional commits: +```bash +git commit -m "feat: add new scraping mode" +git commit -m "fix: handle timeout errors" +git commit -m "docs: update API reference" +``` + +Generate changelog: +```bash +# Use git-cliff or similar +git cliff --tag v1.2.3 > CHANGELOG.md +``` + +#### 3. Pre-Release Testing + +Add test workflow: +```yaml +# .github/workflows/test.yml +on: + push: + tags: + - 'test-v*' + +jobs: + test-release: + runs-on: ubuntu-latest + steps: + - name: Build package + run: python -m build + - name: Upload to TestPyPI + run: twine upload --repository testpypi dist/* +``` + +#### 4. Release Checklist + +Create issue template: +```markdown +## Release Checklist + +- [ ] Update version in `crawl4ai/__version__.py` +- [ ] Update CHANGELOG.md +- [ ] Run tests locally: `pytest` +- [ ] Build package locally: `python -m build` +- [ ] Create and push tag: `git tag v1.2.3 && git push origin v1.2.3` +- [ ] Monitor Release Pipeline workflow +- [ ] Monitor Docker Release workflow +- [ ] Verify PyPI: `pip install crawl4ai==1.2.3` +- [ ] Verify Docker: `docker pull unclecode/crawl4ai:1.2.3` +- [ ] Announce release +``` + +--- + +## References + +### Official Documentation + +- [GitHub Actions Documentation](https://docs.github.com/en/actions) +- [Docker Build Push Action](https://github.com/docker/build-push-action) +- [PyPI Publishing Guide](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/) + +### Related Files + +- [`release.yml`](../release.yml) - Main release workflow +- [`docker-release.yml`](../docker-release.yml) - Docker build workflow +- [`release.yml.backup`](../release.yml.backup) - Original combined workflow + +### Changelog + +| Date | Version | Changes | +|------|---------|---------| +| 2025-01-XX | 2.0 | Split workflows, added Docker caching | +| 2024-XX-XX | 1.0 | Initial combined workflow | + +--- + +## Support + +For issues or questions: +1. Check [Troubleshooting](#troubleshooting) section +2. Review [GitHub Actions logs](../../actions) +3. Create issue in repository + +--- + +**Last Updated**: 2025-01-21 +**Maintainer**: Crawl4AI Team diff --git a/.github/workflows/docs/WORKFLOW_REFERENCE.md b/.github/workflows/docs/WORKFLOW_REFERENCE.md new file mode 100644 index 00000000..208b4d62 --- /dev/null +++ b/.github/workflows/docs/WORKFLOW_REFERENCE.md @@ -0,0 +1,287 @@ +# Workflow Quick Reference + +## Quick Commands + +### Standard Release +```bash +# 1. Update version +vim crawl4ai/__version__.py # Set to "1.2.3" + +# 2. Commit and tag +git add crawl4ai/__version__.py +git commit -m "chore: bump version to 1.2.3" +git tag v1.2.3 +git push origin main +git push origin v1.2.3 + +# 3. Monitor +# - PyPI: ~2-3 minutes +# - Docker: ~1-15 minutes +``` + +### Docker Rebuild Only +```bash +git tag docker-rebuild-v1.2.3 +git push origin docker-rebuild-v1.2.3 +``` + +### Delete Tag (Undo Release) +```bash +# Local +git tag -d v1.2.3 + +# Remote +git push --delete origin v1.2.3 + +# GitHub Release +gh release delete v1.2.3 +``` + +--- + +## Workflow Triggers + +### release.yml +| Event | Pattern | Example | +|-------|---------|---------| +| Tag push | `v*` | `v1.2.3` | +| Excludes | `test-v*` | `test-v1.2.3` | + +### docker-release.yml +| Event | Pattern | Example | +|-------|---------|---------| +| Release published | `release.published` | Automatic | +| Tag push | `docker-rebuild-v*` | `docker-rebuild-v1.2.3` | + +--- + +## Environment Variables + +### release.yml +| Variable | Source | Example | +|----------|--------|---------| +| `VERSION` | Git tag | `1.2.3` | +| `TWINE_USERNAME` | Static | `__token__` | +| `TWINE_PASSWORD` | Secret | `pypi-Ag...` | +| `GITHUB_TOKEN` | Auto | `ghp_...` | + +### docker-release.yml +| Variable | Source | Example | +|----------|--------|---------| +| `VERSION` | Release/Tag | `1.2.3` | +| `MAJOR` | Computed | `1` | +| `MINOR` | Computed | `1.2` | +| `DOCKER_USERNAME` | Secret | `unclecode` | +| `DOCKER_TOKEN` | Secret | `dckr_pat_...` | + +--- + +## Docker Tags Generated + +| Version | Tags Created | +|---------|-------------| +| v1.0.0 | `1.0.0`, `1.0`, `1`, `latest` | +| v1.1.0 | `1.1.0`, `1.1`, `1`, `latest` | +| v1.2.3 | `1.2.3`, `1.2`, `1`, `latest` | +| v2.0.0 | `2.0.0`, `2.0`, `2`, `latest` | + +--- + +## Workflow Outputs + +### release.yml +| Output | Location | Time | +|--------|----------|------| +| PyPI Package | https://pypi.org/project/crawl4ai/ | ~2-3 min | +| GitHub Release | Repository β†’ Releases | ~2-3 min | +| Workflow Summary | Actions β†’ Run β†’ Summary | Immediate | + +### docker-release.yml +| Output | Location | Time | +|--------|----------|------| +| Docker Images | https://hub.docker.com/r/unclecode/crawl4ai | ~1-15 min | +| Workflow Summary | Actions β†’ Run β†’ Summary | Immediate | + +--- + +## Common Issues + +| Issue | Solution | +|-------|----------| +| Version mismatch | Update `crawl4ai/__version__.py` to match tag | +| PyPI 403 Forbidden | Check `PYPI_TOKEN` secret | +| PyPI 400 File exists | Version already published, increment version | +| Docker auth failed | Regenerate `DOCKER_TOKEN` | +| Docker build timeout | Check Dockerfile, review build logs | +| Cache not working | First build on branch always cold | + +--- + +## Secrets Checklist + +- [ ] `PYPI_TOKEN` - PyPI API token (project or account scope) +- [ ] `DOCKER_USERNAME` - Docker Hub username +- [ ] `DOCKER_TOKEN` - Docker Hub access token (read/write) +- [ ] `GITHUB_TOKEN` - Auto-provided (no action needed) + +--- + +## Workflow Dependencies + +### release.yml Dependencies +```yaml +Python: 3.12 +Actions: + - actions/checkout@v4 + - actions/setup-python@v5 + - softprops/action-gh-release@v2 +PyPI Packages: + - build + - twine +``` + +### docker-release.yml Dependencies +```yaml +Actions: + - actions/checkout@v4 + - docker/setup-buildx-action@v3 + - docker/login-action@v3 + - docker/build-push-action@v5 +Docker: + - Buildx + - QEMU (for multi-arch) +``` + +--- + +## Cache Information + +### Type +- GitHub Actions Cache (`type=gha`) + +### Storage +- **Limit**: 10GB per repository +- **Retention**: 7 days for unused entries +- **Cleanup**: Automatic LRU eviction + +### Performance +| Scenario | Cache Hit | Build Time | +|----------|-----------|------------| +| First build | 0% | 10-15 min | +| Code change only | 85% | 1-2 min | +| Dependency update | 60% | 3-5 min | +| No changes | 100% | 30-60 sec | + +--- + +## Build Platforms + +| Platform | Architecture | Devices | +|----------|--------------|---------| +| linux/amd64 | x86_64 | Intel/AMD servers, AWS EC2, GCP | +| linux/arm64 | aarch64 | Apple Silicon, AWS Graviton, Raspberry Pi | + +--- + +## Version Validation + +### Pre-Tag Checklist +```bash +# Check current version +python -c "from crawl4ai.__version__ import __version__; print(__version__)" + +# Verify it matches intended tag +# If tag is v1.2.3, version should be "1.2.3" +``` + +### Post-Release Verification +```bash +# PyPI +pip install crawl4ai==1.2.3 +python -c "import crawl4ai; print(crawl4ai.__version__)" + +# Docker +docker pull unclecode/crawl4ai:1.2.3 +docker run unclecode/crawl4ai:1.2.3 python -c "import crawl4ai; print(crawl4ai.__version__)" +``` + +--- + +## Monitoring URLs + +| Service | URL | +|---------|-----| +| GitHub Actions | `https://github.com/{owner}/{repo}/actions` | +| PyPI Project | `https://pypi.org/project/crawl4ai/` | +| Docker Hub | `https://hub.docker.com/r/unclecode/crawl4ai` | +| GitHub Releases | `https://github.com/{owner}/{repo}/releases` | + +--- + +## Rollback Strategy + +### PyPI (Cannot Delete) +```bash +# Increment patch version +git tag v1.2.4 +git push origin v1.2.4 +``` + +### Docker (Can Overwrite) +```bash +# Rebuild with fix +git tag docker-rebuild-v1.2.3 +git push origin docker-rebuild-v1.2.3 +``` + +### GitHub Release +```bash +# Delete release +gh release delete v1.2.3 + +# Delete tag +git push --delete origin v1.2.3 +``` + +--- + +## Status Badge Markdown + +```markdown +[![Release Pipeline](https://github.com/{owner}/{repo}/actions/workflows/release.yml/badge.svg)](https://github.com/{owner}/{repo}/actions/workflows/release.yml) + +[![Docker Release](https://github.com/{owner}/{repo}/actions/workflows/docker-release.yml/badge.svg)](https://github.com/{owner}/{repo}/actions/workflows/docker-release.yml) +``` + +--- + +## Timeline Example + +``` +0:00 - Push tag v1.2.3 +0:01 - release.yml starts +0:02 - Version validation passes +0:03 - Package built +0:04 - PyPI upload starts +0:06 - PyPI upload complete βœ“ +0:07 - GitHub release created βœ“ +0:08 - release.yml complete +0:08 - docker-release.yml triggered +0:10 - Docker build starts +0:12 - amd64 image built (cache hit) +0:14 - arm64 image built (cache hit) +0:15 - Images pushed to Docker Hub βœ“ +0:16 - docker-release.yml complete + +Total: ~16 minutes +Critical path (PyPI + GitHub): ~8 minutes +``` + +--- + +## Contact + +For workflow issues: +1. Check Actions tab for logs +2. Review this reference +3. See [README.md](./README.md) for detailed docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ee9042c..bf1ad7dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,53 +10,53 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # Required for creating releases - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - + - name: Extract version from tag id: get_version run: | TAG_VERSION=${GITHUB_REF#refs/tags/v} echo "VERSION=$TAG_VERSION" >> $GITHUB_OUTPUT echo "Releasing version: $TAG_VERSION" - + - name: Install package dependencies run: | pip install -e . - + - name: Check version consistency run: | TAG_VERSION=${{ steps.get_version.outputs.VERSION }} PACKAGE_VERSION=$(python -c "from crawl4ai.__version__ import __version__; print(__version__)") - + echo "Tag version: $TAG_VERSION" echo "Package version: $PACKAGE_VERSION" - + if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then echo "❌ Version mismatch! Tag: $TAG_VERSION, Package: $PACKAGE_VERSION" echo "Please update crawl4ai/__version__.py to match the tag version" exit 1 fi echo "βœ… Version check passed: $TAG_VERSION" - + - name: Install build dependencies run: | python -m pip install --upgrade pip pip install build twine - + - name: Build package run: python -m build - + - name: Check package run: twine check dist/* - + - name: Upload to PyPI env: TWINE_USERNAME: __token__ @@ -65,37 +65,7 @@ jobs: echo "πŸ“¦ Uploading to PyPI..." twine upload dist/* echo "βœ… Package uploaded to https://pypi.org/project/crawl4ai/" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Extract major and minor versions - id: versions - run: | - VERSION=${{ steps.get_version.outputs.VERSION }} - MAJOR=$(echo $VERSION | cut -d. -f1) - MINOR=$(echo $VERSION | cut -d. -f1-2) - echo "MAJOR=$MAJOR" >> $GITHUB_OUTPUT - echo "MINOR=$MINOR" >> $GITHUB_OUTPUT - - - name: Build and push Docker images - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: | - unclecode/crawl4ai:${{ steps.get_version.outputs.VERSION }} - unclecode/crawl4ai:${{ steps.versions.outputs.MINOR }} - unclecode/crawl4ai:${{ steps.versions.outputs.MAJOR }} - unclecode/crawl4ai:latest - platforms: linux/amd64,linux/arm64 - + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -103,26 +73,29 @@ jobs: name: Release v${{ steps.get_version.outputs.VERSION }} body: | ## πŸŽ‰ Crawl4AI v${{ steps.get_version.outputs.VERSION }} Released! - + ### πŸ“¦ Installation - + **PyPI:** ```bash pip install crawl4ai==${{ steps.get_version.outputs.VERSION }} ``` - + **Docker:** ```bash docker pull unclecode/crawl4ai:${{ steps.get_version.outputs.VERSION }} docker pull unclecode/crawl4ai:latest ``` - + + **Note:** Docker images are being built and will be available shortly. + Check the [Docker Release workflow](https://github.com/${{ github.repository }}/actions/workflows/docker-release.yml) for build status. + ### πŸ“ What's Changed See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details. draft: false prerelease: false token: ${{ secrets.GITHUB_TOKEN }} - + - name: Summary run: | echo "## πŸš€ Release Complete!" >> $GITHUB_STEP_SUMMARY @@ -132,11 +105,9 @@ jobs: echo "- URL: https://pypi.org/project/crawl4ai/" >> $GITHUB_STEP_SUMMARY echo "- Install: \`pip install crawl4ai==${{ steps.get_version.outputs.VERSION }}\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "### 🐳 Docker Images" >> $GITHUB_STEP_SUMMARY - echo "- \`unclecode/crawl4ai:${{ steps.get_version.outputs.VERSION }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`unclecode/crawl4ai:${{ steps.versions.outputs.MINOR }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`unclecode/crawl4ai:${{ steps.versions.outputs.MAJOR }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`unclecode/crawl4ai:latest\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY echo "### πŸ“‹ GitHub Release" >> $GITHUB_STEP_SUMMARY - echo "https://github.com/${{ github.repository }}/releases/tag/v${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "- https://github.com/${{ github.repository }}/releases/tag/v${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🐳 Docker Images" >> $GITHUB_STEP_SUMMARY + echo "Docker images are being built in a separate workflow." >> $GITHUB_STEP_SUMMARY + echo "Check: https://github.com/${{ github.repository }}/actions/workflows/docker-release.yml" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml.backup b/.github/workflows/release.yml.backup new file mode 100644 index 00000000..3ee9042c --- /dev/null +++ b/.github/workflows/release.yml.backup @@ -0,0 +1,142 @@ +name: Release Pipeline +on: + push: + tags: + - 'v*' + - '!test-v*' # Exclude test tags + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write # Required for creating releases + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Extract version from tag + id: get_version + run: | + TAG_VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$TAG_VERSION" >> $GITHUB_OUTPUT + echo "Releasing version: $TAG_VERSION" + + - name: Install package dependencies + run: | + pip install -e . + + - name: Check version consistency + run: | + TAG_VERSION=${{ steps.get_version.outputs.VERSION }} + PACKAGE_VERSION=$(python -c "from crawl4ai.__version__ import __version__; print(__version__)") + + echo "Tag version: $TAG_VERSION" + echo "Package version: $PACKAGE_VERSION" + + if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then + echo "❌ Version mismatch! Tag: $TAG_VERSION, Package: $PACKAGE_VERSION" + echo "Please update crawl4ai/__version__.py to match the tag version" + exit 1 + fi + echo "βœ… Version check passed: $TAG_VERSION" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Check package + run: twine check dist/* + + - name: Upload to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + echo "πŸ“¦ Uploading to PyPI..." + twine upload dist/* + echo "βœ… Package uploaded to https://pypi.org/project/crawl4ai/" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Extract major and minor versions + id: versions + run: | + VERSION=${{ steps.get_version.outputs.VERSION }} + MAJOR=$(echo $VERSION | cut -d. -f1) + MINOR=$(echo $VERSION | cut -d. -f1-2) + echo "MAJOR=$MAJOR" >> $GITHUB_OUTPUT + echo "MINOR=$MINOR" >> $GITHUB_OUTPUT + + - name: Build and push Docker images + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + unclecode/crawl4ai:${{ steps.get_version.outputs.VERSION }} + unclecode/crawl4ai:${{ steps.versions.outputs.MINOR }} + unclecode/crawl4ai:${{ steps.versions.outputs.MAJOR }} + unclecode/crawl4ai:latest + platforms: linux/amd64,linux/arm64 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.get_version.outputs.VERSION }} + name: Release v${{ steps.get_version.outputs.VERSION }} + body: | + ## πŸŽ‰ Crawl4AI v${{ steps.get_version.outputs.VERSION }} Released! + + ### πŸ“¦ Installation + + **PyPI:** + ```bash + pip install crawl4ai==${{ steps.get_version.outputs.VERSION }} + ``` + + **Docker:** + ```bash + docker pull unclecode/crawl4ai:${{ steps.get_version.outputs.VERSION }} + docker pull unclecode/crawl4ai:latest + ``` + + ### πŸ“ What's Changed + See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details. + draft: false + prerelease: false + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + echo "## πŸš€ Release Complete!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### πŸ“¦ PyPI Package" >> $GITHUB_STEP_SUMMARY + echo "- Version: ${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY + echo "- URL: https://pypi.org/project/crawl4ai/" >> $GITHUB_STEP_SUMMARY + echo "- Install: \`pip install crawl4ai==${{ steps.get_version.outputs.VERSION }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🐳 Docker Images" >> $GITHUB_STEP_SUMMARY + echo "- \`unclecode/crawl4ai:${{ steps.get_version.outputs.VERSION }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`unclecode/crawl4ai:${{ steps.versions.outputs.MINOR }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`unclecode/crawl4ai:${{ steps.versions.outputs.MAJOR }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`unclecode/crawl4ai:latest\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### πŸ“‹ GitHub Release" >> $GITHUB_STEP_SUMMARY + echo "https://github.com/${{ github.repository }}/releases/tag/v${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.gitignore b/.gitignore index a5389a3e..4679b80c 100644 --- a/.gitignore +++ b/.gitignore @@ -266,6 +266,8 @@ continue_config.json .llm.env .private/ +.claude/ + CLAUDE_MONITOR.md CLAUDE.md diff --git a/docs/md_v2/assets/crawl4ai-skill.zip b/docs/md_v2/assets/crawl4ai-skill.zip new file mode 100644 index 00000000..21785b02 Binary files /dev/null and b/docs/md_v2/assets/crawl4ai-skill.zip differ diff --git a/docs/md_v2/complete-sdk-reference.md b/docs/md_v2/complete-sdk-reference.md new file mode 100644 index 00000000..d4a5ba65 --- /dev/null +++ b/docs/md_v2/complete-sdk-reference.md @@ -0,0 +1,5196 @@ +# Crawl4AI Complete SDK Documentation + +**Generated:** 2025-10-19 12:56 +**Format:** Ultra-Dense Reference (Optimized for AI Assistants) +**Crawl4AI Version:** 0.7.4 + +--- + +## Navigation + + +- [Installation & Setup](#installation--setup) +- [Quick Start](#quick-start) +- [Core API](#core-api) +- [Configuration](#configuration) +- [Crawling Patterns](#crawling-patterns) +- [Content Processing](#content-processing) +- [Extraction Strategies](#extraction-strategies) +- [Advanced Features](#advanced-features) + +--- + + +# Installation & Setup + +# Installation & Setup (2023 Edition) +## 1. Basic Installation +```bash +pip install crawl4ai +``` +## 2. Initial Setup & Diagnostics +### 2.1 Run the Setup Command +```bash +crawl4ai-setup +``` +- Performs OS-level checks (e.g., missing libs on Linux) +- Confirms your environment is ready to crawl +### 2.2 Diagnostics +```bash +crawl4ai-doctor +``` +- Check Python version compatibility +- Verify Playwright installation +- Inspect environment variables or library conflicts +If any issues arise, follow its suggestions (e.g., installing additional system packages) and re-run `crawl4ai-setup`. +## 3. Verifying Installation: A Simple Crawl (Skip this step if you already run `crawl4ai-doctor`) +Below is a minimal Python script demonstrating a **basic** crawl. It uses our new **`BrowserConfig`** and **`CrawlerRunConfig`** for clarity, though no custom settings are passed in this example: +```python +import asyncio +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig + +async def main(): + async with AsyncWebCrawler() as crawler: + result = await crawler.arun( + url="https://www.example.com", + ) + print(result.markdown[:300]) # Show the first 300 characters of extracted text + +if __name__ == "__main__": + asyncio.run(main()) +``` +- A headless browser session loads `example.com` +- Crawl4AI returns ~300 characters of markdown. +If errors occur, rerun `crawl4ai-doctor` or manually ensure Playwright is installed correctly. +## 4. Advanced Installation (Optional) +### 4.1 Torch, Transformers, or All +- **Text Clustering (Torch)** + ```bash + pip install crawl4ai[torch] + crawl4ai-setup + ``` +- **Transformers** + ```bash + pip install crawl4ai[transformer] + crawl4ai-setup + ``` +- **All Features** + ```bash + pip install crawl4ai[all] + crawl4ai-setup + ``` +```bash +crawl4ai-download-models +``` +## 5. Docker (Experimental) +```bash +docker pull unclecode/crawl4ai:basic +docker run -p 11235:11235 unclecode/crawl4ai:basic +``` +You can then make POST requests to `http://localhost:11235/crawl` to perform crawls.β€€**Production usage** is discouraged until our new Docker approach is ready (planned in Jan or Feb 2025). +## 6. Local Server Mode (Legacy) +## Summary +1.β€€**Install** with `pip install crawl4ai` and run `crawl4ai-setup`. +2.β€€**Diagnose** with `crawl4ai-doctor` if you see errors. +3.β€€**Verify** by crawling `example.com` with minimal `BrowserConfig` + `CrawlerRunConfig`. + + + +# Quick Start + +# Getting Started with Crawl4AI +1. Run your **first crawl** using minimal configuration. +3. Experiment with a simple **CSS-based extraction** strategy. +5. Crawl a **dynamic** page that loads content via JavaScript. +## 1. Introduction +- An asynchronous crawler, **`AsyncWebCrawler`**. +- Configurable browser and run settings via **`BrowserConfig`** and **`CrawlerRunConfig`**. +- Automatic HTML-to-Markdown conversion via **`DefaultMarkdownGenerator`** (supports optional filters). +- Multiple extraction strategies (LLM-based or β€œtraditional” CSS/XPath-based). +## 2. Your First Crawl +Here’s a minimal Python script that creates an **`AsyncWebCrawler`**, fetches a webpage, and prints the first 300 characters of its Markdown output: +```python +import asyncio +from crawl4ai import AsyncWebCrawler + +async def main(): + async with AsyncWebCrawler() as crawler: + result = await crawler.arun("https://example.com") + print(result.markdown[:300]) # Print first 300 chars + +if __name__ == "__main__": + asyncio.run(main()) +``` +- **`AsyncWebCrawler`** launches a headless browser (Chromium by default). +- It fetches `https://example.com`. +- Crawl4AI automatically converts the HTML into Markdown. +## 3. Basic Configuration (Light Introduction) +1.β€€**`BrowserConfig`**: Controls browser behavior (headless or full UI, user agent, JavaScript toggles, etc.). +2.β€€**`CrawlerRunConfig`**: Controls how each crawl runs (caching, extraction, timeouts, hooking, etc.). +```python +import asyncio +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode + +async def main(): + browser_conf = BrowserConfig(headless=True) # or False to see the browser + run_conf = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS + ) + + async with AsyncWebCrawler(config=browser_conf) as crawler: + result = await crawler.arun( + url="https://example.com", + config=run_conf + ) + print(result.markdown) + +if __name__ == "__main__": + asyncio.run(main()) +``` +> IMPORTANT: By default cache mode is set to `CacheMode.BYPASS` to have fresh content. Set `CacheMode.ENABLED` to enable caching. +## 4. Generating Markdown Output +- **`result.markdown`**: +- **`result.markdown.fit_markdown`**: + The same content after applying any configured **content filter** (e.g., `PruningContentFilter`). +### Example: Using a Filter with `DefaultMarkdownGenerator` +```python +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig +from crawl4ai.content_filter_strategy import PruningContentFilter +from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator + +md_generator = DefaultMarkdownGenerator( + content_filter=PruningContentFilter(threshold=0.4, threshold_type="fixed") +) + +config = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + markdown_generator=md_generator +) + +async with AsyncWebCrawler() as crawler: + result = await crawler.arun("https://news.ycombinator.com", config=config) + print("Raw Markdown length:", len(result.markdown.raw_markdown)) + print("Fit Markdown length:", len(result.markdown.fit_markdown)) +``` +**Note**: If you do **not** specify a content filter or markdown generator, you’ll typically see only the raw Markdown. `PruningContentFilter` may adds around `50ms` in processing time. We’ll dive deeper into these strategies in a dedicated **Markdown Generation** tutorial. +## 5. Simple Data Extraction (CSS-based) +```python +from crawl4ai import JsonCssExtractionStrategy +from crawl4ai import LLMConfig + +# Generate a schema (one-time cost) +html = "

Gaming Laptop

$999.99
" + +# Using OpenAI (requires API token) +schema = JsonCssExtractionStrategy.generate_schema( + html, + llm_config = LLMConfig(provider="openai/gpt-4o",api_token="your-openai-token") # Required for OpenAI +) + +# Or using Ollama (open source, no token needed) +schema = JsonCssExtractionStrategy.generate_schema( + html, + llm_config = LLMConfig(provider="ollama/llama3.3", api_token=None) # Not needed for Ollama +) + +# Use the schema for fast, repeated extractions +strategy = JsonCssExtractionStrategy(schema) +``` +```python +import asyncio +import json +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig, CacheMode +from crawl4ai import JsonCssExtractionStrategy + +async def main(): + schema = { + "name": "Example Items", + "baseSelector": "div.item", + "fields": [ + {"name": "title", "selector": "h2", "type": "text"}, + {"name": "link", "selector": "a", "type": "attribute", "attribute": "href"} + ] + } + + raw_html = "

Item 1

Link 1
" + + async with AsyncWebCrawler() as crawler: + result = await crawler.arun( + url="raw://" + raw_html, + config=CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + extraction_strategy=JsonCssExtractionStrategy(schema) + ) + ) + # The JSON output is stored in 'extracted_content' + data = json.loads(result.extracted_content) + print(data) + +if __name__ == "__main__": + asyncio.run(main()) +``` +- Great for repetitive page structures (e.g., item listings, articles). +- No AI usage or costs. +- The crawler returns a JSON string you can parse or store. +> Tips: You can pass raw HTML to the crawler instead of a URL. To do so, prefix the HTML with `raw://`. +## 6. Simple Data Extraction (LLM-based) +- **Open-Source Models** (e.g., `ollama/llama3.3`, `no_token`) +- **OpenAI Models** (e.g., `openai/gpt-4`, requires `api_token`) +- Or any provider supported by the underlying library +```python +import os +import json +import asyncio +from pydantic import BaseModel, Field +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig, LLMConfig +from crawl4ai import LLMExtractionStrategy + +class OpenAIModelFee(BaseModel): + model_name: str = Field(..., description="Name of the OpenAI model.") + input_fee: str = Field(..., description="Fee for input token for the OpenAI model.") + output_fee: str = Field( + ..., description="Fee for output token for the OpenAI model." + ) + +async def extract_structured_data_using_llm( + provider: str, api_token: str = None, extra_headers: Dict[str, str] = None +): + print(f"\n--- Extracting Structured Data with {provider} ---") + + if api_token is None and provider != "ollama": + print(f"API token is required for {provider}. Skipping this example.") + return + + browser_config = BrowserConfig(headless=True) + + extra_args = {"temperature": 0, "top_p": 0.9, "max_tokens": 2000} + if extra_headers: + extra_args["extra_headers"] = extra_headers + + crawler_config = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + word_count_threshold=1, + page_timeout=80000, + extraction_strategy=LLMExtractionStrategy( + llm_config = LLMConfig(provider=provider,api_token=api_token), + schema=OpenAIModelFee.model_json_schema(), + extraction_type="schema", + instruction="""From the crawled content, extract all mentioned model names along with their fees for input and output tokens. + Do not miss any models in the entire content.""", + extra_args=extra_args, + ), + ) + + async with AsyncWebCrawler(config=browser_config) as crawler: + result = await crawler.arun( + url="https://openai.com/api/pricing/", config=crawler_config + ) + print(result.extracted_content) + +if __name__ == "__main__": + + asyncio.run( + extract_structured_data_using_llm( + provider="openai/gpt-4o", api_token=os.getenv("OPENAI_API_KEY") + ) + ) +``` +- We define a Pydantic schema (`PricingInfo`) describing the fields we want. +## 7. Adaptive Crawling (New!) +```python +import asyncio +from crawl4ai import AsyncWebCrawler, AdaptiveCrawler + +async def adaptive_example(): + async with AsyncWebCrawler() as crawler: + adaptive = AdaptiveCrawler(crawler) + + # Start adaptive crawling + result = await adaptive.digest( + start_url="https://docs.python.org/3/", + query="async context managers" + ) + + # View results + adaptive.print_stats() + print(f"Crawled {len(result.crawled_urls)} pages") + print(f"Achieved {adaptive.confidence:.0%} confidence") + +if __name__ == "__main__": + asyncio.run(adaptive_example()) +``` +- **Automatic stopping**: Stops when sufficient information is gathered +- **Intelligent link selection**: Follows only relevant links +- **Confidence scoring**: Know how complete your information is +## 8. Multi-URL Concurrency (Preview) +If you need to crawl multiple URLs in **parallel**, you can use `arun_many()`. By default, Crawl4AI employs a **MemoryAdaptiveDispatcher**, automatically adjusting concurrency based on system resources. Here’s a quick glimpse: +```python +import asyncio +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig, CacheMode + +async def quick_parallel_example(): + urls = [ + "https://example.com/page1", + "https://example.com/page2", + "https://example.com/page3" + ] + + run_conf = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + stream=True # Enable streaming mode + ) + + async with AsyncWebCrawler() as crawler: + # Stream results as they complete + async for result in await crawler.arun_many(urls, config=run_conf): + if result.success: + print(f"[OK] {result.url}, length: {len(result.markdown.raw_markdown)}") + else: + print(f"[ERROR] {result.url} => {result.error_message}") + + # Or get all results at once (default behavior) + run_conf = run_conf.clone(stream=False) + results = await crawler.arun_many(urls, config=run_conf) + for res in results: + if res.success: + print(f"[OK] {res.url}, length: {len(res.markdown.raw_markdown)}") + else: + print(f"[ERROR] {res.url} => {res.error_message}") + +if __name__ == "__main__": + asyncio.run(quick_parallel_example()) +``` +1. **Streaming mode** (`stream=True`): Process results as they become available using `async for` +2. **Batch mode** (`stream=False`): Wait for all results to complete +## 8. Dynamic Content Example +Some sites require multiple β€œpage clicks” or dynamic JavaScript updates. Below is an example showing how to **click** a β€œNext Page” button and wait for new commits to load on GitHub, using **`BrowserConfig`** and **`CrawlerRunConfig`**: +```python +import asyncio +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode +from crawl4ai import JsonCssExtractionStrategy + +async def extract_structured_data_using_css_extractor(): + print("\n--- Using JsonCssExtractionStrategy for Fast Structured Output ---") + schema = { + "name": "KidoCode Courses", + "baseSelector": "section.charge-methodology .w-tab-content > div", + "fields": [ + { + "name": "section_title", + "selector": "h3.heading-50", + "type": "text", + }, + { + "name": "section_description", + "selector": ".charge-content", + "type": "text", + }, + { + "name": "course_name", + "selector": ".text-block-93", + "type": "text", + }, + { + "name": "course_description", + "selector": ".course-content-text", + "type": "text", + }, + { + "name": "course_icon", + "selector": ".image-92", + "type": "attribute", + "attribute": "src", + }, + ], + } + + browser_config = BrowserConfig(headless=True, java_script_enabled=True) + + js_click_tabs = """ + (async () => { + const tabs = document.querySelectorAll("section.charge-methodology .tabs-menu-3 > div"); + for(let tab of tabs) { + tab.scrollIntoView(); + tab.click(); + await new Promise(r => setTimeout(r, 500)); + } + })(); + """ + + crawler_config = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + extraction_strategy=JsonCssExtractionStrategy(schema), + js_code=[js_click_tabs], + ) + + async with AsyncWebCrawler(config=browser_config) as crawler: + result = await crawler.arun( + url="https://www.kidocode.com/degrees/technology", config=crawler_config + ) + + companies = json.loads(result.extracted_content) + print(f"Successfully extracted {len(companies)} companies") + print(json.dumps(companies[0], indent=2)) + +async def main(): + await extract_structured_data_using_css_extractor() + +if __name__ == "__main__": + asyncio.run(main()) +``` +- **`BrowserConfig(headless=False)`**: We want to watch it click β€œNext Page.” +- **`CrawlerRunConfig(...)`**: We specify the extraction strategy, pass `session_id` to reuse the same page. +- **`js_code`** and **`wait_for`** are used for subsequent pages (`page > 0`) to click the β€œNext” button and wait for new commits to load. +- **`js_only=True`** indicates we’re not re-navigating but continuing the existing session. +- Finally, we call `kill_session()` to clean up the page and browser session. +## 9. Next Steps +1. Performed a basic crawl and printed Markdown. +2. Used **content filters** with a markdown generator. +3. Extracted JSON via **CSS** or **LLM** strategies. +4. Handled **dynamic** pages with JavaScript triggers. + + + +# Core API + +# AsyncWebCrawler +The **`AsyncWebCrawler`** is the core class for asynchronous web crawling in Crawl4AI.β€€You typically create it **once**, optionally customize it with a **`BrowserConfig`** (e.g., headless, user agent), then **run** multiple **`arun()`** calls with different **`CrawlerRunConfig`** objects. +1.β€€**Create** a `BrowserConfig` for global browser settings.β€€ +2.β€€**Instantiate** `AsyncWebCrawler(config=browser_config)`.β€€ +3.β€€**Use** the crawler in an async context manager (`async with`) or manage start/close manually.β€€ +4.β€€**Call** `arun(url, config=crawler_run_config)` for each page you want. +## 1.β€€Constructor Overview +```python +class AsyncWebCrawler: + def __init__( + self, + crawler_strategy: Optional[AsyncCrawlerStrategy] = None, + config: Optional[BrowserConfig] = None, + always_bypass_cache: bool = False, # deprecated + always_by_pass_cache: Optional[bool] = None, # also deprecated + base_directory: str = ..., + thread_safe: bool = False, + **kwargs, + ): + """ + Create an AsyncWebCrawler instance. + + Args: + crawler_strategy: + (Advanced) Provide a custom crawler strategy if needed. + config: + A BrowserConfig object specifying how the browser is set up. + always_bypass_cache: + (Deprecated) Use CrawlerRunConfig.cache_mode instead. + base_directory: + Folder for storing caches/logs (if relevant). + thread_safe: + If True, attempts some concurrency safeguards.β€€Usually False. + **kwargs: + Additional legacy or debugging parameters. + """ + ) + +### Typical Initialization + +```python +from crawl4ai import AsyncWebCrawler, BrowserConfig +browser_cfg = BrowserConfig( + browser_type="chromium", + headless=True, + verbose=True +crawler = AsyncWebCrawler(config=browser_cfg) +``` + +**Notes**: + +- **Legacy** parameters like `always_bypass_cache` remain for backward compatibility, but prefer to set **caching** in `CrawlerRunConfig`. + +--- + +## 2.β€€Lifecycle: Start/Close or Context Manager + +### 2.1 Context Manager (Recommended) + +```python +async with AsyncWebCrawler(config=browser_cfg) as crawler: + result = await crawler.arun("https://example.com") + # The crawler automatically starts/closes resources +``` + +When the `async with` block ends, the crawler cleans up (closes the browser, etc.). + +### 2.2 Manual Start & Close + +```python +crawler = AsyncWebCrawler(config=browser_cfg) +await crawler.start() +result1 = await crawler.arun("https://example.com") +result2 = await crawler.arun("https://another.com") +await crawler.close() +``` + +Use this style if you have a **long-running** application or need full control of the crawler’s lifecycle. + +--- + +## 3.β€€Primary Method: `arun()` + +```python +async def arun( + url: str, + config: Optional[CrawlerRunConfig] = None, + # Legacy parameters for backward compatibility... +``` + +### 3.1 New Approach + +You pass a `CrawlerRunConfig` object that sets up everything about a crawlβ€”content filtering, caching, session reuse, JS code, screenshots, etc. + +```python +import asyncio +from crawl4ai import CrawlerRunConfig, CacheMode +run_cfg = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + css_selector="main.article", + word_count_threshold=10, + screenshot=True +async with AsyncWebCrawler(config=browser_cfg) as crawler: + result = await crawler.arun("https://example.com/news", config=run_cfg) +``` + +### 3.2 Legacy Parameters Still Accepted + +For **backward** compatibility, `arun()` can still accept direct arguments like `css_selector=...`, `word_count_threshold=...`, etc., but we strongly advise migrating them into a **`CrawlerRunConfig`**. + +--- + +## 4.β€€Batch Processing: `arun_many()` + +```python +async def arun_many( + urls: List[str], + config: Optional[CrawlerRunConfig] = None, + # Legacy parameters maintained for backwards compatibility... +``` + +### 4.1 Resource-Aware Crawling + +The `arun_many()` method now uses an intelligent dispatcher that: + +- Monitors system memory usage +- Implements adaptive rate limiting +- Provides detailed progress monitoring +- Manages concurrent crawls efficiently + +### 4.2 Example Usage + +Check page [Multi-url Crawling](../advanced/multi-url-crawling.md) for a detailed example of how to use `arun_many()`. + +```python +### 4.3 Key Features +1.β€€**Rate Limiting** + - Automatic delay between requests + - Exponential backoff on rate limit detection + - Domain-specific rate limiting + - Configurable retry strategy +2.β€€**Resource Monitoring** + - Memory usage tracking + - Adaptive concurrency based on system load + - Automatic pausing when resources are constrained +3.β€€**Progress Monitoring** + - Detailed or aggregated progress display + - Real-time status updates + - Memory usage statistics +4.β€€**Error Handling** + - Graceful handling of rate limits + - Automatic retries with backoff + - Detailed error reporting +## 5.β€€`CrawlResult` Output +Each `arun()` returns a **`CrawlResult`** containing: +- `url`: Final URL (if redirected). +- `html`: Original HTML. +- `cleaned_html`: Sanitized HTML. +- `markdown_v2`: Deprecated. Instead just use regular `markdown` +- `extracted_content`: If an extraction strategy was used (JSON for CSS/LLM strategies). +- `screenshot`, `pdf`: If screenshots/PDF requested. +- `media`, `links`: Information about discovered images/links. +- `success`, `error_message`: Status info. +## 6.β€€Quick Example +```python +import asyncio +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode +from crawl4ai import JsonCssExtractionStrategy +import json + +async def main(): + # 1.β€€Browser config + browser_cfg = BrowserConfig( + browser_type="firefox", + headless=False, + verbose=True + ) + + # 2.β€€Run config + schema = { + "name": "Articles", + "baseSelector": "article.post", + "fields": [ + { + "name": "title", + "selector": "h2", + "type": "text" + }, + { + "name": "url", + "selector": "a", + "type": "attribute", + "attribute": "href" + } + ] + } + + run_cfg = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + extraction_strategy=JsonCssExtractionStrategy(schema), + word_count_threshold=15, + remove_overlay_elements=True, + wait_for="css:.post" # Wait for posts to appear + ) + + async with AsyncWebCrawler(config=browser_cfg) as crawler: + result = await crawler.arun( + url="https://example.com/blog", + config=run_cfg + ) + + if result.success: + print("Cleaned HTML length:", len(result.cleaned_html)) + if result.extracted_content: + articles = json.loads(result.extracted_content) + print("Extracted articles:", articles[:2]) + else: + print("Error:", result.error_message) + +asyncio.run(main()) +``` +- We define a **`BrowserConfig`** with Firefox, no headless, and `verbose=True`.β€€ +- We define a **`CrawlerRunConfig`** that **bypasses cache**, uses a **CSS** extraction schema, has a `word_count_threshold=15`, etc.β€€ +- We pass them to `AsyncWebCrawler(config=...)` and `arun(url=..., config=...)`. +## 7.β€€Best Practices & Migration Notes +1.β€€**Use** `BrowserConfig` for **global** settings about the browser’s environment.β€€ +2.β€€**Use** `CrawlerRunConfig` for **per-crawl** logic (caching, content filtering, extraction strategies, wait conditions).β€€ +3.β€€**Avoid** legacy parameters like `css_selector` or `word_count_threshold` directly in `arun()`.β€€Instead: + ```python + run_cfg = CrawlerRunConfig(css_selector=".main-content", word_count_threshold=20) + result = await crawler.arun(url="...", config=run_cfg) + ``` +## 8.β€€Summary +- **Constructor** accepts **`BrowserConfig`** (or defaults).β€€ +- **`arun(url, config=CrawlerRunConfig)`** is the main method for single-page crawls.β€€ +- **`arun_many(urls, config=CrawlerRunConfig)`** handles concurrency across multiple URLs.β€€ +- For advanced lifecycle control, use `start()` and `close()` explicitly.β€€ +- If you used `AsyncWebCrawler(browser_type="chromium", css_selector="...")`, move browser settings to `BrowserConfig(...)` and content/crawl logic to `CrawlerRunConfig(...)`. + + +# `arun()` Parameter Guide (New Approach) +In Crawl4AI’s **latest** configuration model, nearly all parameters that once went directly to `arun()` are now part of **`CrawlerRunConfig`**.β€€When calling `arun()`, you provide: +```python +await crawler.arun( + url="https://example.com", + config=my_run_config +) +``` +Below is an organized look at the parameters that can go inside `CrawlerRunConfig`, divided by their functional areas.β€€For **Browser** settings (e.g., `headless`, `browser_type`), see [BrowserConfig](./parameters.md). +## 1.β€€Core Usage +```python +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig, CacheMode + +async def main(): + run_config = CrawlerRunConfig( + verbose=True, # Detailed logging + cache_mode=CacheMode.ENABLED, # Use normal read/write cache + check_robots_txt=True, # Respect robots.txt rules + # ...β€€other parameters + ) + + async with AsyncWebCrawler() as crawler: + result = await crawler.arun( + url="https://example.com", + config=run_config + ) + + # Check if blocked by robots.txt + if not result.success and result.status_code == 403: + print(f"Error: {result.error_message}") +``` +- `verbose=True` logs each crawl step.β€€ +- `cache_mode` decides how to read/write the local crawl cache. +## 2.β€€Cache Control +**`cache_mode`** (default: `CacheMode.ENABLED`) +Use a built-in enum from `CacheMode`: +- `ENABLED`: Normal cachingβ€”reads if available, writes if missing. +- `DISABLED`: No cachingβ€”always refetch pages. +- `READ_ONLY`: Reads from cache only; no new writes. +- `WRITE_ONLY`: Writes to cache but doesn’t read existing data. +- `BYPASS`: Skips reading cache for this crawl (though it might still write if set up that way). +```python +run_config = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS +) +``` +- `bypass_cache=True` acts like `CacheMode.BYPASS`. +- `disable_cache=True` acts like `CacheMode.DISABLED`. +- `no_cache_read=True` acts like `CacheMode.WRITE_ONLY`. +- `no_cache_write=True` acts like `CacheMode.READ_ONLY`. +## 3.β€€Content Processing & Selection +### 3.1 Text Processing +```python +run_config = CrawlerRunConfig( + word_count_threshold=10, # Ignore text blocks <10 words + only_text=False, # If True, tries to remove non-text elements + keep_data_attributes=False # Keep or discard data-* attributes +) +``` +### 3.2 Content Selection +```python +run_config = CrawlerRunConfig( + css_selector=".main-content", # Focus on .main-content region only + excluded_tags=["form", "nav"], # Remove entire tag blocks + remove_forms=True, # Specifically strip
elements + remove_overlay_elements=True, # Attempt to remove modals/popups +) +``` +### 3.3 Link Handling +```python +run_config = CrawlerRunConfig( + exclude_external_links=True, # Remove external links from final content + exclude_social_media_links=True, # Remove links to known social sites + exclude_domains=["ads.example.com"], # Exclude links to these domains + exclude_social_media_domains=["facebook.com","twitter.com"], # Extend the default list +) +``` +### 3.4 Media Filtering +```python +run_config = CrawlerRunConfig( + exclude_external_images=True # Strip images from other domains +) +``` +## 4.β€€Page Navigation & Timing +### 4.1 Basic Browser Flow +```python +run_config = CrawlerRunConfig( + wait_for="css:.dynamic-content", # Wait for .dynamic-content + delay_before_return_html=2.0, # Wait 2s before capturing final HTML + page_timeout=60000, # Navigation & script timeout (ms) +) +``` +- `wait_for`: + - `"css:selector"` or + - `"js:() => boolean"` + e.g.β€€`js:() => document.querySelectorAll('.item').length > 10`. +- `mean_delay` & `max_range`: define random delays for `arun_many()` calls.β€€ +- `semaphore_count`: concurrency limit when crawling multiple URLs. +### 4.2 JavaScript Execution +```python +run_config = CrawlerRunConfig( + js_code=[ + "window.scrollTo(0, document.body.scrollHeight);", + "document.querySelector('.load-more')?.click();" + ], + js_only=False +) +``` +- `js_code` can be a single string or a list of strings.β€€ +- `js_only=True` means β€œI’m continuing in the same session with new JS steps, no new full navigation.” +### 4.3 Anti-Bot +```python +run_config = CrawlerRunConfig( + magic=True, + simulate_user=True, + override_navigator=True +) +``` +- `magic=True` tries multiple stealth features.β€€ +- `simulate_user=True` mimics mouse movements or random delays.β€€ +- `override_navigator=True` fakes some navigator properties (like user agent checks). +## 5.β€€Session Management +**`session_id`**: +```python +run_config = CrawlerRunConfig( + session_id="my_session123" +) +``` +If re-used in subsequent `arun()` calls, the same tab/page context is continued (helpful for multi-step tasks or stateful browsing). +## 6.β€€Screenshot, PDF & Media Options +```python +run_config = CrawlerRunConfig( + screenshot=True, # Grab a screenshot as base64 + screenshot_wait_for=1.0, # Wait 1s before capturing + pdf=True, # Also produce a PDF + image_description_min_word_threshold=5, # If analyzing alt text + image_score_threshold=3, # Filter out low-score images +) +``` +- `result.screenshot` β†’ Base64 screenshot string. +- `result.pdf` β†’ Byte array with PDF data. +## 7.β€€Extraction Strategy +**For advanced data extraction** (CSS/LLM-based), set `extraction_strategy`: +```python +run_config = CrawlerRunConfig( + extraction_strategy=my_css_or_llm_strategy +) +``` +The extracted data will appear in `result.extracted_content`. +## 8.β€€Comprehensive Example +Below is a snippet combining many parameters: +```python +import asyncio +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig, CacheMode +from crawl4ai import JsonCssExtractionStrategy + +async def main(): + # Example schema + schema = { + "name": "Articles", + "baseSelector": "article.post", + "fields": [ + {"name": "title", "selector": "h2", "type": "text"}, + {"name": "link", "selector": "a", "type": "attribute", "attribute": "href"} + ] + } + + run_config = CrawlerRunConfig( + # Core + verbose=True, + cache_mode=CacheMode.ENABLED, + check_robots_txt=True, # Respect robots.txt rules + + # Content + word_count_threshold=10, + css_selector="main.content", + excluded_tags=["nav", "footer"], + exclude_external_links=True, + + # Page & JS + js_code="document.querySelector('.show-more')?.click();", + wait_for="css:.loaded-block", + page_timeout=30000, + + # Extraction + extraction_strategy=JsonCssExtractionStrategy(schema), + + # Session + session_id="persistent_session", + + # Media + screenshot=True, + pdf=True, + + # Anti-bot + simulate_user=True, + magic=True, + ) + + async with AsyncWebCrawler() as crawler: + result = await crawler.arun("https://example.com/posts", config=run_config) + if result.success: + print("HTML length:", len(result.cleaned_html)) + print("Extraction JSON:", result.extracted_content) + if result.screenshot: + print("Screenshot length:", len(result.screenshot)) + if result.pdf: + print("PDF bytes length:", len(result.pdf)) + else: + print("Error:", result.error_message) + +if __name__ == "__main__": + asyncio.run(main()) +``` +1.β€€**Crawling** the main content region, ignoring external links.β€€ +2.β€€Running **JavaScript** to click β€œ.show-more”.β€€ +3.β€€**Waiting** for β€œ.loaded-block” to appear.β€€ +4.β€€Generating a **screenshot** & **PDF** of the final page.β€€ +## 9.β€€Best Practices +1.β€€**Use `BrowserConfig` for global browser** settings (headless, user agent).β€€ +2.β€€**Use `CrawlerRunConfig`** to handle the **specific** crawl needs: content filtering, caching, JS, screenshot, extraction, etc.β€€ +4.β€€**Limit** large concurrency (`semaphore_count`) if the site or your system can’t handle it.β€€ +5.β€€For dynamic pages, set `js_code` or `scan_full_page` so you load all content. +## 10.β€€Conclusion +All parameters that used to be direct arguments to `arun()` now belong in **`CrawlerRunConfig`**.β€€This approach: +- Makes code **clearer** and **more maintainable**.β€€ + + +# `arun_many(...)` Reference +> **Note**: This function is very similar to [`arun()`](./arun.md) but focused on **concurrent** or **batch** crawling.β€€If you’re unfamiliar with `arun()` usage, please read that doc first, then review this for differences. +## Function Signature +```python +async def arun_many( + urls: Union[List[str], List[Any]], + config: Optional[Union[CrawlerRunConfig, List[CrawlerRunConfig]]] = None, + dispatcher: Optional[BaseDispatcher] = None, + ... +) -> Union[List[CrawlResult], AsyncGenerator[CrawlResult, None]]: + """ + Crawl multiple URLs concurrently or in batches. + + :param urls: A list of URLs (or tasks) to crawl. + :param config: (Optional) Either: + - A single `CrawlerRunConfig` applying to all URLs + - A list of `CrawlerRunConfig` objects with url_matcher patterns + :param dispatcher: (Optional) A concurrency controller (e.g.β€€MemoryAdaptiveDispatcher). + ... + :return: Either a list of `CrawlResult` objects, or an async generator if streaming is enabled. + """ +``` +## Differences from `arun()` +1.β€€**Multiple URLs**: + - Instead of crawling a single URL, you pass a list of them (strings or tasks).β€€ + - The function returns either a **list** of `CrawlResult` or an **async generator** if streaming is enabled. +2.β€€**Concurrency & Dispatchers**: + - **`dispatcher`** param allows advanced concurrency control.β€€ + - If omitted, a default dispatcher (like `MemoryAdaptiveDispatcher`) is used internally.β€€ +3.β€€**Streaming Support**: + - Enable streaming by setting `stream=True` in your `CrawlerRunConfig`. + - When streaming, use `async for` to process results as they become available. +4.β€€**Parallel** Execution**: + - `arun_many()` can run multiple requests concurrently under the hood.β€€ + - Each `CrawlResult` might also include a **`dispatch_result`** with concurrency details (like memory usage, start/end times). +### Basic Example (Batch Mode) +```python +# Minimal usage: The default dispatcher will be used +results = await crawler.arun_many( + urls=["https://site1.com", "https://site2.com"], + config=CrawlerRunConfig(stream=False) # Default behavior +) + +for res in results: + if res.success: + print(res.url, "crawled OK!") + else: + print("Failed:", res.url, "-", res.error_message) +``` +### Streaming Example +```python +config = CrawlerRunConfig( + stream=True, # Enable streaming mode + cache_mode=CacheMode.BYPASS +) + +# Process results as they complete +async for result in await crawler.arun_many( + urls=["https://site1.com", "https://site2.com", "https://site3.com"], + config=config +): + if result.success: + print(f"Just completed: {result.url}") + # Process each result immediately + process_result(result) +``` +### With a Custom Dispatcher +```python +dispatcher = MemoryAdaptiveDispatcher( + memory_threshold_percent=70.0, + max_session_permit=10 +) +results = await crawler.arun_many( + urls=["https://site1.com", "https://site2.com", "https://site3.com"], + config=my_run_config, + dispatcher=dispatcher +) +``` +### URL-Specific Configurations +Instead of using one config for all URLs, provide a list of configs with `url_matcher` patterns: +```python +from crawl4ai import CrawlerRunConfig, MatchMode +from crawl4ai.processors.pdf import PDFContentScrapingStrategy +from crawl4ai.extraction_strategy import JsonCssExtractionStrategy +from crawl4ai.content_filter_strategy import PruningContentFilter +from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator + +# PDF files - specialized extraction +pdf_config = CrawlerRunConfig( + url_matcher="*.pdf", + scraping_strategy=PDFContentScrapingStrategy() +) + +# Blog/article pages - content filtering +blog_config = CrawlerRunConfig( + url_matcher=["*/blog/*", "*/article/*", "*python.org*"], + markdown_generator=DefaultMarkdownGenerator( + content_filter=PruningContentFilter(threshold=0.48) + ) +) + +# Dynamic pages - JavaScript execution +github_config = CrawlerRunConfig( + url_matcher=lambda url: 'github.com' in url, + js_code="window.scrollTo(0, 500);" +) + +# API endpoints - JSON extraction +api_config = CrawlerRunConfig( + url_matcher=lambda url: 'api' in url or url.endswith('.json'), + # Custome settings for JSON extraction +) + +# Default fallback config +default_config = CrawlerRunConfig() # No url_matcher means it never matches except as fallback + +# Pass the list of configs - first match wins! +results = await crawler.arun_many( + urls=[ + "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", # β†’ pdf_config + "https://blog.python.org/", # β†’ blog_config + "https://github.com/microsoft/playwright", # β†’ github_config + "https://httpbin.org/json", # β†’ api_config + "https://example.com/" # β†’ default_config + ], + config=[pdf_config, blog_config, github_config, api_config, default_config] +) +``` +- **String patterns**: `"*.pdf"`, `"*/blog/*"`, `"*python.org*"` +- **Function matchers**: `lambda url: 'api' in url` +- **Mixed patterns**: Combine strings and functions with `MatchMode.OR` or `MatchMode.AND` +- **First match wins**: Configs are evaluated in order +- `dispatch_result` in each `CrawlResult` (if using concurrency) can hold memory and timing info.β€€ +- **Important**: Always include a default config (without `url_matcher`) as the last item if you want to handle all URLs. Otherwise, unmatched URLs will fail. +### Return Value +Either a **list** of [`CrawlResult`](./crawl-result.md) objects, or an **async generator** if streaming is enabled.β€€You can iterate to check `result.success` or read each item’s `extracted_content`, `markdown`, or `dispatch_result`. +## Dispatcher Reference +- **`MemoryAdaptiveDispatcher`**: Dynamically manages concurrency based on system memory usage.β€€ +- **`SemaphoreDispatcher`**: Fixed concurrency limit, simpler but less adaptive.β€€ +## Common Pitfalls +3.β€€**Error Handling**: Each `CrawlResult` might fail for different reasonsβ€”always check `result.success` or the `error_message` before proceeding. +## Conclusion +Use `arun_many()` when you want to **crawl multiple URLs** simultaneously or in controlled parallel tasks.β€€If you need advanced concurrency features (like memory-based adaptive throttling or complex rate-limiting), provide a **dispatcher**.β€€Each result is a standard `CrawlResult`, possibly augmented with concurrency stats (`dispatch_result`) for deeper inspection.β€€For more details on concurrency logic and dispatchers, see the [Advanced Multi-URL Crawling](../advanced/multi-url-crawling.md) docs. + + +# `CrawlResult` Reference +The **`CrawlResult`** class encapsulates everything returned after a single crawl operation. It provides the **raw or processed content**, details on links and media, plus optional metadata (like screenshots, PDFs, or extracted JSON). +**Location**: `crawl4ai/crawler/models.py` (for reference) +```python +class CrawlResult(BaseModel): + url: str + html: str + success: bool + cleaned_html: Optional[str] = None + fit_html: Optional[str] = None # Preprocessed HTML optimized for extraction + media: Dict[str, List[Dict]] = {} + links: Dict[str, List[Dict]] = {} + downloaded_files: Optional[List[str]] = None + screenshot: Optional[str] = None + pdf : Optional[bytes] = None + mhtml: Optional[str] = None + markdown: Optional[Union[str, MarkdownGenerationResult]] = None + extracted_content: Optional[str] = None + metadata: Optional[dict] = None + error_message: Optional[str] = None + session_id: Optional[str] = None + response_headers: Optional[dict] = None + status_code: Optional[int] = None + ssl_certificate: Optional[SSLCertificate] = None + dispatch_result: Optional[DispatchResult] = None + ... +``` +## 1. Basic Crawl Info +### 1.1 **`url`** *(str)* +```python +print(result.url) # e.g., "https://example.com/" +``` +### 1.2 **`success`** *(bool)* +**What**: `True` if the crawl pipeline ended without major errors; `False` otherwise. +```python +if not result.success: + print(f"Crawl failed: {result.error_message}") +``` +### 1.3 **`status_code`** *(Optional[int])* +```python +if result.status_code == 404: + print("Page not found!") +``` +### 1.4 **`error_message`** *(Optional[str])* +**What**: If `success=False`, a textual description of the failure. +```python +if not result.success: + print("Error:", result.error_message) +``` +### 1.5 **`session_id`** *(Optional[str])* +```python +# If you used session_id="login_session" in CrawlerRunConfig, see it here: +print("Session:", result.session_id) +``` +### 1.6 **`response_headers`** *(Optional[dict])* +```python +if result.response_headers: + print("Server:", result.response_headers.get("Server", "Unknown")) +``` +### 1.7 **`ssl_certificate`** *(Optional[SSLCertificate])* +**What**: If `fetch_ssl_certificate=True` in your CrawlerRunConfig, **`result.ssl_certificate`** contains a [**`SSLCertificate`**](../advanced/ssl-certificate.md) object describing the site's certificate. You can export the cert in multiple formats (PEM/DER/JSON) or access its properties like `issuer`, + `subject`, `valid_from`, `valid_until`, etc. +```python +if result.ssl_certificate: + print("Issuer:", result.ssl_certificate.issuer) +``` +## 2. Raw / Cleaned Content +### 2.1 **`html`** *(str)* +```python +# Possibly large +print(len(result.html)) +``` +### 2.2 **`cleaned_html`** *(Optional[str])* +**What**: A sanitized HTML versionβ€”scripts, styles, or excluded tags are removed based on your `CrawlerRunConfig`. +```python +print(result.cleaned_html[:500]) # Show a snippet +``` +## 3. Markdown Fields +### 3.1 The Markdown Generation Approach +- **Raw** markdown +- **Links as citations** (with a references section) +- **Fit** markdown if a **content filter** is used (like Pruning or BM25) +**`MarkdownGenerationResult`** includes: +- **`raw_markdown`** *(str)*: The full HTMLβ†’Markdown conversion. +- **`markdown_with_citations`** *(str)*: Same markdown, but with link references as academic-style citations. +- **`references_markdown`** *(str)*: The reference list or footnotes at the end. +- **`fit_markdown`** *(Optional[str])*: If content filtering (Pruning/BM25) was applied, the filtered "fit" text. +- **`fit_html`** *(Optional[str])*: The HTML that led to `fit_markdown`. +```python +if result.markdown: + md_res = result.markdown + print("Raw MD:", md_res.raw_markdown[:300]) + print("Citations MD:", md_res.markdown_with_citations[:300]) + print("References:", md_res.references_markdown) + if md_res.fit_markdown: + print("Pruned text:", md_res.fit_markdown[:300]) +``` +### 3.2 **`markdown`** *(Optional[Union[str, MarkdownGenerationResult]])* +**What**: Holds the `MarkdownGenerationResult`. +```python +print(result.markdown.raw_markdown[:200]) +print(result.markdown.fit_markdown) +print(result.markdown.fit_html) +``` +**Important**: "Fit" content (in `fit_markdown`/`fit_html`) exists in result.markdown, only if you used a **filter** (like **PruningContentFilter** or **BM25ContentFilter**) within a `MarkdownGenerationStrategy`. +## 4. Media & Links +### 4.1 **`media`** *(Dict[str, List[Dict]])* +**What**: Contains info about discovered images, videos, or audio. Typically keys: `"images"`, `"videos"`, `"audios"`. +- `src` *(str)*: Media URL +- `alt` or `title` *(str)*: Descriptive text +- `score` *(float)*: Relevance score if the crawler's heuristic found it "important" +- `desc` or `description` *(Optional[str])*: Additional context extracted from surrounding text +```python +images = result.media.get("images", []) +for img in images: + if img.get("score", 0) > 5: + print("High-value image:", img["src"]) +``` +### 4.2 **`links`** *(Dict[str, List[Dict]])* +**What**: Holds internal and external link data. Usually two keys: `"internal"` and `"external"`. +- `href` *(str)*: The link target +- `text` *(str)*: Link text +- `title` *(str)*: Title attribute +- `context` *(str)*: Surrounding text snippet +- `domain` *(str)*: If external, the domain +```python +for link in result.links["internal"]: + print(f"Internal link to {link['href']} with text {link['text']}") +``` +## 5. Additional Fields +### 5.1 **`extracted_content`** *(Optional[str])* +**What**: If you used **`extraction_strategy`** (CSS, LLM, etc.), the structured output (JSON). +```python +if result.extracted_content: + data = json.loads(result.extracted_content) + print(data) +``` +### 5.2 **`downloaded_files`** *(Optional[List[str]])* +**What**: If `accept_downloads=True` in your `BrowserConfig` + `downloads_path`, lists local file paths for downloaded items. +```python +if result.downloaded_files: + for file_path in result.downloaded_files: + print("Downloaded:", file_path) +``` +### 5.3 **`screenshot`** *(Optional[str])* +**What**: Base64-encoded screenshot if `screenshot=True` in `CrawlerRunConfig`. +```python +import base64 +if result.screenshot: + with open("page.png", "wb") as f: + f.write(base64.b64decode(result.screenshot)) +``` +### 5.4 **`pdf`** *(Optional[bytes])* +**What**: Raw PDF bytes if `pdf=True` in `CrawlerRunConfig`. +```python +if result.pdf: + with open("page.pdf", "wb") as f: + f.write(result.pdf) +``` +### 5.5 **`mhtml`** *(Optional[str])* +**What**: MHTML snapshot of the page if `capture_mhtml=True` in `CrawlerRunConfig`. MHTML (MIME HTML) format preserves the entire web page with all its resources (CSS, images, scripts, etc.) in a single file. +```python +if result.mhtml: + with open("page.mhtml", "w", encoding="utf-8") as f: + f.write(result.mhtml) +``` +### 5.6 **`metadata`** *(Optional[dict])* +```python +if result.metadata: + print("Title:", result.metadata.get("title")) + print("Author:", result.metadata.get("author")) +``` +## 6. `dispatch_result` (optional) +A `DispatchResult` object providing additional concurrency and resource usage information when crawling URLs in parallel (e.g., via `arun_many()` with custom dispatchers). It contains: +- **`task_id`**: A unique identifier for the parallel task. +- **`memory_usage`** (float): The memory (in MB) used at the time of completion. +- **`peak_memory`** (float): The peak memory usage (in MB) recorded during the task's execution. +- **`start_time`** / **`end_time`** (datetime): Time range for this crawling task. +- **`error_message`** (str): Any dispatcher- or concurrency-related error encountered. +```python +# Example usage: +for result in results: + if result.success and result.dispatch_result: + dr = result.dispatch_result + print(f"URL: {result.url}, Task ID: {dr.task_id}") + print(f"Memory: {dr.memory_usage:.1f} MB (Peak: {dr.peak_memory:.1f} MB)") + print(f"Duration: {dr.end_time - dr.start_time}") +``` +> **Note**: This field is typically populated when using `arun_many(...)` alongside a **dispatcher** (e.g., `MemoryAdaptiveDispatcher` or `SemaphoreDispatcher`). If no concurrency or dispatcher is used, `dispatch_result` may remain `None`. +## 7. Network Requests & Console Messages +When you enable network and console message capturing in `CrawlerRunConfig` using `capture_network_requests=True` and `capture_console_messages=True`, the `CrawlResult` will include these fields: +### 7.1 **`network_requests`** *(Optional[List[Dict[str, Any]]])* +- Each item has an `event_type` field that can be `"request"`, `"response"`, or `"request_failed"`. +- Request events include `url`, `method`, `headers`, `post_data`, `resource_type`, and `is_navigation_request`. +- Response events include `url`, `status`, `status_text`, `headers`, and `request_timing`. +- Failed request events include `url`, `method`, `resource_type`, and `failure_text`. +- All events include a `timestamp` field. +```python +if result.network_requests: + # Count different types of events + requests = [r for r in result.network_requests if r.get("event_type") == "request"] + responses = [r for r in result.network_requests if r.get("event_type") == "response"] + failures = [r for r in result.network_requests if r.get("event_type") == "request_failed"] + + print(f"Captured {len(requests)} requests, {len(responses)} responses, and {len(failures)} failures") + + # Analyze API calls + api_calls = [r for r in requests if "api" in r.get("url", "")] + + # Identify failed resources + for failure in failures: + print(f"Failed to load: {failure.get('url')} - {failure.get('failure_text')}") +``` +### 7.2 **`console_messages`** *(Optional[List[Dict[str, Any]]])* +- Each item has a `type` field indicating the message type (e.g., `"log"`, `"error"`, `"warning"`, etc.). +- The `text` field contains the actual message text. +- Some messages include `location` information (URL, line, column). +- All messages include a `timestamp` field. +```python +if result.console_messages: + # Count messages by type + message_types = {} + for msg in result.console_messages: + msg_type = msg.get("type", "unknown") + message_types[msg_type] = message_types.get(msg_type, 0) + 1 + + print(f"Message type counts: {message_types}") + + # Display errors (which are usually most important) + for msg in result.console_messages: + if msg.get("type") == "error": + print(f"Error: {msg.get('text')}") +``` +## 8. Example: Accessing Everything +```python +async def handle_result(result: CrawlResult): + if not result.success: + print("Crawl error:", result.error_message) + return + + # Basic info + print("Crawled URL:", result.url) + print("Status code:", result.status_code) + + # HTML + print("Original HTML size:", len(result.html)) + print("Cleaned HTML size:", len(result.cleaned_html or "")) + + # Markdown output + if result.markdown: + print("Raw Markdown:", result.markdown.raw_markdown[:300]) + print("Citations Markdown:", result.markdown.markdown_with_citations[:300]) + if result.markdown.fit_markdown: + print("Fit Markdown:", result.markdown.fit_markdown[:200]) + + # Media & Links + if "images" in result.media: + print("Image count:", len(result.media["images"])) + if "internal" in result.links: + print("Internal link count:", len(result.links["internal"])) + + # Extraction strategy result + if result.extracted_content: + print("Structured data:", result.extracted_content) + + # Screenshot/PDF/MHTML + if result.screenshot: + print("Screenshot length:", len(result.screenshot)) + if result.pdf: + print("PDF bytes length:", len(result.pdf)) + if result.mhtml: + print("MHTML length:", len(result.mhtml)) + + # Network and console capturing + if result.network_requests: + print(f"Network requests captured: {len(result.network_requests)}") + # Analyze request types + req_types = {} + for req in result.network_requests: + if "resource_type" in req: + req_types[req["resource_type"]] = req_types.get(req["resource_type"], 0) + 1 + print(f"Resource types: {req_types}") + + if result.console_messages: + print(f"Console messages captured: {len(result.console_messages)}") + # Count by message type + msg_types = {} + for msg in result.console_messages: + msg_types[msg.get("type", "unknown")] = msg_types.get(msg.get("type", "unknown"), 0) + 1 + print(f"Message types: {msg_types}") +``` +## 9. Key Points & Future +1.β€€**Deprecated legacy properties of CrawlResult** + - `markdown_v2` - Deprecated in v0.5. Just use `markdown`. It holds the `MarkdownGenerationResult` now! + - `fit_markdown` and `fit_html` - Deprecated in v0.5. They can now be accessed via `MarkdownGenerationResult` in `result.markdown`. eg: `result.markdown.fit_markdown` and `result.markdown.fit_html` +2.β€€**Fit Content** + - **`fit_markdown`** and **`fit_html`** appear in MarkdownGenerationResult, only if you used a content filter (like **PruningContentFilter** or **BM25ContentFilter**) inside your **MarkdownGenerationStrategy** or set them directly. + - If no filter is used, they remain `None`. +3.β€€**References & Citations** + - If you enable link citations in your `DefaultMarkdownGenerator` (`options={"citations": True}`), you’ll see `markdown_with_citations` plus a **`references_markdown`** block. This helps large language models or academic-like referencing. +4.β€€**Links & Media** + - `links["internal"]` and `links["external"]` group discovered anchors by domain. + - `media["images"]` / `["videos"]` / `["audios"]` store extracted media elements with optional scoring or context. +5.β€€**Error Cases** + - If `success=False`, check `error_message` (e.g., timeouts, invalid URLs). + - `status_code` might be `None` if we failed before an HTTP response. +Use **`CrawlResult`** to glean all final outputs and feed them into your data pipelines, AI models, or archives. With the synergy of a properly configured **BrowserConfig** and **CrawlerRunConfig**, the crawler can produce robust, structured results here in **`CrawlResult`**. + + + +# Configuration + +# Browser, Crawler & LLM Configuration (Quick Overview) +Crawl4AI's flexibility stems from two key classes: +1. **`BrowserConfig`** – Dictates **how** the browser is launched and behaves (e.g., headless or visible, proxy, user agent). +2. **`CrawlerRunConfig`** – Dictates **how** each **crawl** operates (e.g., caching, extraction, timeouts, JavaScript code to run, etc.). +3. **`LLMConfig`** - Dictates **how** LLM providers are configured. (model, api token, base url, temperature etc.) +In most examples, you create **one** `BrowserConfig` for the entire crawler session, then pass a **fresh** or re-used `CrawlerRunConfig` whenever you call `arun()`. This tutorial shows the most commonly used parameters. If you need advanced or rarely used fields, see the [Configuration Parameters](../api/parameters.md). +## 1. BrowserConfig Essentials +```python +class BrowserConfig: + def __init__( + browser_type="chromium", + headless=True, + proxy_config=None, + viewport_width=1080, + viewport_height=600, + verbose=True, + use_persistent_context=False, + user_data_dir=None, + cookies=None, + headers=None, + user_agent=None, + text_mode=False, + light_mode=False, + extra_args=None, + enable_stealth=False, + # ... other advanced parameters omitted here + ): + ... +``` +### Key Fields to Note +1. **`browser_type`** +- Options: `"chromium"`, `"firefox"`, or `"webkit"`. +- Defaults to `"chromium"`. +- If you need a different engine, specify it here. +2. **`headless`** + - `True`: Runs the browser in headless mode (invisible browser). + - `False`: Runs the browser in visible mode, which helps with debugging. +3. **`proxy_config`** + - A dictionary with fields like: +```json +{ + "server": "http://proxy.example.com:8080", + "username": "...", + "password": "..." +} +``` + - Leave as `None` if a proxy is not required. +4. **`viewport_width` & `viewport_height`**: + - The initial window size. + - Some sites behave differently with smaller or bigger viewports. +5. **`verbose`**: + - If `True`, prints extra logs. + - Handy for debugging. +6. **`use_persistent_context`**: + - If `True`, uses a **persistent** browser profile, storing cookies/local storage across runs. + - Typically also set `user_data_dir` to point to a folder. +7. **`cookies`** & **`headers`**: + - E.g. `cookies=[{"name": "session", "value": "abc123", "domain": "example.com"}]`. +8. **`user_agent`**: + - Custom User-Agent string. If `None`, a default is used. + - You can also set `user_agent_mode="random"` for randomization (if you want to fight bot detection). +9. **`text_mode`** & **`light_mode`**: + - `text_mode=True` disables images, possibly speeding up text-only crawls. + - `light_mode=True` turns off certain background features for performance. +10. **`extra_args`**: + - Additional flags for the underlying browser. + - E.g. `["--disable-extensions"]`. +11. **`enable_stealth`**: + - If `True`, enables stealth mode using playwright-stealth. + - Modifies browser fingerprints to avoid basic bot detection. + - Default is `False`. Recommended for sites with bot protection. +### Helper Methods +Both configuration classes provide a `clone()` method to create modified copies: +```python +# Create a base browser config +base_browser = BrowserConfig( + browser_type="chromium", + headless=True, + text_mode=True +) + +# Create a visible browser config for debugging +debug_browser = base_browser.clone( + headless=False, + verbose=True +) +``` +```python +from crawl4ai import AsyncWebCrawler, BrowserConfig + +browser_conf = BrowserConfig( + browser_type="firefox", + headless=False, + text_mode=True +) + +async with AsyncWebCrawler(config=browser_conf) as crawler: + result = await crawler.arun("https://example.com") + print(result.markdown[:300]) +``` +## 2. CrawlerRunConfig Essentials +```python +class CrawlerRunConfig: + def __init__( + word_count_threshold=200, + extraction_strategy=None, + markdown_generator=None, + cache_mode=None, + js_code=None, + wait_for=None, + screenshot=False, + pdf=False, + capture_mhtml=False, + # Location and Identity Parameters + locale=None, # e.g. "en-US", "fr-FR" + timezone_id=None, # e.g. "America/New_York" + geolocation=None, # GeolocationConfig object + # Resource Management + enable_rate_limiting=False, + rate_limit_config=None, + memory_threshold_percent=70.0, + check_interval=1.0, + max_session_permit=20, + display_mode=None, + verbose=True, + stream=False, # Enable streaming for arun_many() + # ... other advanced parameters omitted + ): + ... +``` +### Key Fields to Note +1. **`word_count_threshold`**: + - The minimum word count before a block is considered. + - If your site has lots of short paragraphs or items, you can lower it. +2. **`extraction_strategy`**: + - Where you plug in JSON-based extraction (CSS, LLM, etc.). + - If `None`, no structured extraction is done (only raw/cleaned HTML + markdown). +3. **`markdown_generator`**: + - E.g., `DefaultMarkdownGenerator(...)`, controlling how HTMLβ†’Markdown conversion is done. + - If `None`, a default approach is used. +4. **`cache_mode`**: + - Controls caching behavior (`ENABLED`, `BYPASS`, `DISABLED`, etc.). + - If `None`, defaults to some level of caching or you can specify `CacheMode.ENABLED`. +5. **`js_code`**: + - A string or list of JS strings to execute. + - Great for "Load More" buttons or user interactions. +6. **`wait_for`**: + - A CSS or JS expression to wait for before extracting content. + - Common usage: `wait_for="css:.main-loaded"` or `wait_for="js:() => window.loaded === true"`. +7. **`screenshot`**, **`pdf`**, & **`capture_mhtml`**: + - If `True`, captures a screenshot, PDF, or MHTML snapshot after the page is fully loaded. + - The results go to `result.screenshot` (base64), `result.pdf` (bytes), or `result.mhtml` (string). +8. **Location Parameters**: + - **`locale`**: Browser's locale (e.g., `"en-US"`, `"fr-FR"`) for language preferences + - **`timezone_id`**: Browser's timezone (e.g., `"America/New_York"`, `"Europe/Paris"`) + - **`geolocation`**: GPS coordinates via `GeolocationConfig(latitude=48.8566, longitude=2.3522)` +9. **`verbose`**: + - Logs additional runtime details. + - Overlaps with the browser's verbosity if also set to `True` in `BrowserConfig`. +10. **`enable_rate_limiting`**: + - If `True`, enables rate limiting for batch processing. + - Requires `rate_limit_config` to be set. +11. **`memory_threshold_percent`**: + - The memory threshold (as a percentage) to monitor. + - If exceeded, the crawler will pause or slow down. +12. **`check_interval`**: + - The interval (in seconds) to check system resources. + - Affects how often memory and CPU usage are monitored. +13. **`max_session_permit`**: + - The maximum number of concurrent crawl sessions. + - Helps prevent overwhelming the system. +14. **`url_matcher`** & **`match_mode`**: + - Enable URL-specific configurations when used with `arun_many()`. + - Set `url_matcher` to patterns (glob, function, or list) to match specific URLs. + - Use `match_mode` (OR/AND) to control how multiple patterns combine. +15. **`display_mode`**: + - The display mode for progress information (`DETAILED`, `BRIEF`, etc.). + - Affects how much information is printed during the crawl. +### Helper Methods +The `clone()` method is particularly useful for creating variations of your crawler configuration: +```python +# Create a base configuration +base_config = CrawlerRunConfig( + cache_mode=CacheMode.ENABLED, + word_count_threshold=200, + wait_until="networkidle" +) + +# Create variations for different use cases +stream_config = base_config.clone( + stream=True, # Enable streaming mode + cache_mode=CacheMode.BYPASS +) + +debug_config = base_config.clone( + page_timeout=120000, # Longer timeout for debugging + verbose=True +) +``` +The `clone()` method: +- Creates a new instance with all the same settings +- Updates only the specified parameters +- Leaves the original configuration unchanged +- Perfect for creating variations without repeating all parameters +## 3. LLMConfig Essentials +### Key fields to note +1. **`provider`**: +- Which LLM provider to use. +- Possible values are `"ollama/llama3","groq/llama3-70b-8192","groq/llama3-8b-8192", "openai/gpt-4o-mini" ,"openai/gpt-4o","openai/o1-mini","openai/o1-preview","openai/o3-mini","openai/o3-mini-high","anthropic/claude-3-haiku-20240307","anthropic/claude-3-opus-20240229","anthropic/claude-3-sonnet-20240229","anthropic/claude-3-5-sonnet-20240620","gemini/gemini-pro","gemini/gemini-1.5-pro","gemini/gemini-2.0-flash","gemini/gemini-2.0-flash-exp","gemini/gemini-2.0-flash-lite-preview-02-05","deepseek/deepseek-chat"`
*(default: `"openai/gpt-4o-mini"`)* +2. **`api_token`**: + - Optional. When not provided explicitly, api_token will be read from environment variables based on provider. For example: If a gemini model is passed as provider then,`"GEMINI_API_KEY"` will be read from environment variables + - API token of LLM provider
eg: `api_token = "gsk_1ClHGGJ7Lpn4WGybR7vNWGdyb3FY7zXEw3SCiy0BAVM9lL8CQv"` + - Environment variable - use with prefix "env:"
eg:`api_token = "env: GROQ_API_KEY"` +3. **`base_url`**: + - If your provider has a custom endpoint +```python +llm_config = LLMConfig(provider="openai/gpt-4o-mini", api_token=os.getenv("OPENAI_API_KEY")) +``` +## 4. Putting It All Together +In a typical scenario, you define **one** `BrowserConfig` for your crawler session, then create **one or more** `CrawlerRunConfig` & `LLMConfig` depending on each call's needs: +```python +import asyncio +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode, LLMConfig, LLMContentFilter, DefaultMarkdownGenerator +from crawl4ai import JsonCssExtractionStrategy + +async def main(): + # 1) Browser config: headless, bigger viewport, no proxy + browser_conf = BrowserConfig( + headless=True, + viewport_width=1280, + viewport_height=720 + ) + + # 2) Example extraction strategy + schema = { + "name": "Articles", + "baseSelector": "div.article", + "fields": [ + {"name": "title", "selector": "h2", "type": "text"}, + {"name": "link", "selector": "a", "type": "attribute", "attribute": "href"} + ] + } + extraction = JsonCssExtractionStrategy(schema) + + # 3) Example LLM content filtering + + gemini_config = LLMConfig( + provider="gemini/gemini-1.5-pro", + api_token = "env:GEMINI_API_TOKEN" + ) + + # Initialize LLM filter with specific instruction + filter = LLMContentFilter( + llm_config=gemini_config, # or your preferred provider + instruction=""" + Focus on extracting the core educational content. + Include: + - Key concepts and explanations + - Important code examples + - Essential technical details + Exclude: + - Navigation elements + - Sidebars + - Footer content + Format the output as clean markdown with proper code blocks and headers. + """, + chunk_token_threshold=500, # Adjust based on your needs + verbose=True + ) + + md_generator = DefaultMarkdownGenerator( + content_filter=filter, + options={"ignore_links": True} + ) + + # 4) Crawler run config: skip cache, use extraction + run_conf = CrawlerRunConfig( + markdown_generator=md_generator, + extraction_strategy=extraction, + cache_mode=CacheMode.BYPASS, + ) + + async with AsyncWebCrawler(config=browser_conf) as crawler: + # 4) Execute the crawl + result = await crawler.arun(url="https://example.com/news", config=run_conf) + + if result.success: + print("Extracted content:", result.extracted_content) + else: + print("Error:", result.error_message) + +if __name__ == "__main__": + asyncio.run(main()) +``` +## 5. Next Steps +- [BrowserConfig, CrawlerRunConfig & LLMConfig Reference](../api/parameters.md) +- **Custom Hooks & Auth** (Inject JavaScript or handle login forms). +- **Session Management** (Re-use pages, preserve state across multiple calls). +- **Advanced Caching** (Fine-tune read/write cache modes). +## 6. Conclusion + + +# 1.β€€**BrowserConfig** – Controlling the Browser +`BrowserConfig` focuses on **how** the browser is launched and behaves. This includes headless mode, proxies, user agents, and other environment tweaks. +```python +from crawl4ai import AsyncWebCrawler, BrowserConfig + +browser_cfg = BrowserConfig( + browser_type="chromium", + headless=True, + viewport_width=1280, + viewport_height=720, + proxy="http://user:pass@proxy:8080", + user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/116.0.0.0 Safari/537.36", +) +``` +## 1.1 Parameter Highlights +| **Parameter** | **Type / Default** | **What It Does** | +|-----------------------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| +| **`browser_type`** | `"chromium"`, `"firefox"`, `"webkit"`
*(default: `"chromium"`)* | Which browser engine to use. `"chromium"` is typical for many sites, `"firefox"` or `"webkit"` for specialized tests. | +| **`headless`** | `bool` (default: `True`) | Headless means no visible UI. `False` is handy for debugging. | +| **`viewport_width`** | `int` (default: `1080`) | Initial page width (in px). Useful for testing responsive layouts. | +| **`viewport_height`** | `int` (default: `600`) | Initial page height (in px). | +| **`proxy`** | `str` (deprecated) | Deprecated. Use `proxy_config` instead. If set, it will be auto-converted internally. | +| **`proxy_config`** | `dict` (default: `None`) | For advanced or multi-proxy needs, specify details like `{"server": "...", "username": "...", ...}`. | +| **`use_persistent_context`** | `bool` (default: `False`) | If `True`, uses a **persistent** browser context (keep cookies, sessions across runs). Also sets `use_managed_browser=True`. | +| **`user_data_dir`** | `str or None` (default: `None`) | Directory to store user data (profiles, cookies). Must be set if you want permanent sessions. | +| **`ignore_https_errors`** | `bool` (default: `True`) | If `True`, continues despite invalid certificates (common in dev/staging). | +| **`java_script_enabled`** | `bool` (default: `True`) | Disable if you want no JS overhead, or if only static content is needed. | +| **`cookies`** | `list` (default: `[]`) | Pre-set cookies, each a dict like `{"name": "session", "value": "...", "url": "..."}`. | +| **`headers`** | `dict` (default: `{}`) | Extra HTTP headers for every request, e.g. `{"Accept-Language": "en-US"}`. | +| **`user_agent`** | `str` (default: Chrome-based UA) | Your custom or random user agent. `user_agent_mode="random"` can shuffle it. | +| **`light_mode`** | `bool` (default: `False`) | Disables some background features for performance gains. | +| **`text_mode`** | `bool` (default: `False`) | If `True`, tries to disable images/other heavy content for speed. | +| **`use_managed_browser`** | `bool` (default: `False`) | For advanced β€œmanaged” interactions (debugging, CDP usage). Typically set automatically if persistent context is on. | +| **`extra_args`** | `list` (default: `[]`) | Additional flags for the underlying browser process, e.g. `["--disable-extensions"]`. | +- Set `headless=False` to visually **debug** how pages load or how interactions proceed. +- If you need **authentication** storage or repeated sessions, consider `use_persistent_context=True` and specify `user_data_dir`. +- For large pages, you might need a bigger `viewport_width` and `viewport_height` to handle dynamic content. +# 2.β€€**CrawlerRunConfig** – Controlling Each Crawl +While `BrowserConfig` sets up the **environment**, `CrawlerRunConfig` details **how** each **crawl operation** should behave: caching, content filtering, link or domain blocking, timeouts, JavaScript code, etc. +```python +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig + +run_cfg = CrawlerRunConfig( + wait_for="css:.main-content", + word_count_threshold=15, + excluded_tags=["nav", "footer"], + exclude_external_links=True, + stream=True, # Enable streaming for arun_many() +) +``` +## 2.1 Parameter Highlights +### A) **Content Processing** +| **Parameter** | **Type / Default** | **What It Does** | +|------------------------------|--------------------------------------|-------------------------------------------------------------------------------------------------| +| **`word_count_threshold`** | `int` (default: ~200) | Skips text blocks below X words. Helps ignore trivial sections. | +| **`extraction_strategy`** | `ExtractionStrategy` (default: None) | If set, extracts structured data (CSS-based, LLM-based, etc.). | +| **`markdown_generator`** | `MarkdownGenerationStrategy` (None) | If you want specialized markdown output (citations, filtering, chunking, etc.). Can be customized with options such as `content_source` parameter to select the HTML input source ('cleaned_html', 'raw_html', or 'fit_html'). | +| **`css_selector`** | `str` (None) | Retains only the part of the page matching this selector. Affects the entire extraction process. | +| **`target_elements`** | `List[str]` (None) | List of CSS selectors for elements to focus on for markdown generation and data extraction, while still processing the entire page for links, media, etc. Provides more flexibility than `css_selector`. | +| **`excluded_tags`** | `list` (None) | Removes entire tags (e.g. `["script", "style"]`). | +| **`excluded_selector`** | `str` (None) | Like `css_selector` but to exclude. E.g. `"#ads, .tracker"`. | +| **`only_text`** | `bool` (False) | If `True`, tries to extract text-only content. | +| **`prettiify`** | `bool` (False) | If `True`, beautifies final HTML (slower, purely cosmetic). | +| **`keep_data_attributes`** | `bool` (False) | If `True`, preserve `data-*` attributes in cleaned HTML. | +| **`remove_forms`** | `bool` (False) | If `True`, remove all `` elements. | +### B) **Caching & Session** +| **Parameter** | **Type / Default** | **What It Does** | +|-------------------------|------------------------|------------------------------------------------------------------------------------------------------------------------------| +| **`cache_mode`** | `CacheMode or None` | Controls how caching is handled (`ENABLED`, `BYPASS`, `DISABLED`, etc.). If `None`, typically defaults to `ENABLED`. | +| **`session_id`** | `str or None` | Assign a unique ID to reuse a single browser session across multiple `arun()` calls. | +| **`bypass_cache`** | `bool` (False) | If `True`, acts like `CacheMode.BYPASS`. | +| **`disable_cache`** | `bool` (False) | If `True`, acts like `CacheMode.DISABLED`. | +| **`no_cache_read`** | `bool` (False) | If `True`, acts like `CacheMode.WRITE_ONLY` (writes cache but never reads). | +| **`no_cache_write`** | `bool` (False) | If `True`, acts like `CacheMode.READ_ONLY` (reads cache but never writes). | +### C) **Page Navigation & Timing** +| **Parameter** | **Type / Default** | **What It Does** | +|----------------------------|-------------------------|----------------------------------------------------------------------------------------------------------------------| +| **`wait_until`** | `str` (domcontentloaded)| Condition for navigation to β€œcomplete”. Often `"networkidle"` or `"domcontentloaded"`. | +| **`page_timeout`** | `int` (60000 ms) | Timeout for page navigation or JS steps. Increase for slow sites. | +| **`wait_for`** | `str or None` | Wait for a CSS (`"css:selector"`) or JS (`"js:() => bool"`) condition before content extraction. | +| **`wait_for_images`** | `bool` (False) | Wait for images to load before finishing. Slows down if you only want text. | +| **`delay_before_return_html`** | `float` (0.1) | Additional pause (seconds) before final HTML is captured. Good for last-second updates. | +| **`check_robots_txt`** | `bool` (False) | Whether to check and respect robots.txt rules before crawling. If True, caches robots.txt for efficiency. | +| **`mean_delay`** and **`max_range`** | `float` (0.1, 0.3) | If you call `arun_many()`, these define random delay intervals between crawls, helping avoid detection or rate limits. | +| **`semaphore_count`** | `int` (5) | Max concurrency for `arun_many()`. Increase if you have resources for parallel crawls. | +### D) **Page Interaction** +| **Parameter** | **Type / Default** | **What It Does** | +|----------------------------|--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| **`js_code`** | `str or list[str]` (None) | JavaScript to run after load. E.g. `"document.querySelector('button')?.click();"`. | +| **`js_only`** | `bool` (False) | If `True`, indicates we’re reusing an existing session and only applying JS. No full reload. | +| **`ignore_body_visibility`** | `bool` (True) | Skip checking if `` is visible. Usually best to keep `True`. | +| **`scan_full_page`** | `bool` (False) | If `True`, auto-scroll the page to load dynamic content (infinite scroll). | +| **`scroll_delay`** | `float` (0.2) | Delay between scroll steps if `scan_full_page=True`. | +| **`process_iframes`** | `bool` (False) | Inlines iframe content for single-page extraction. | +| **`remove_overlay_elements`** | `bool` (False) | Removes potential modals/popups blocking the main content. | +| **`simulate_user`** | `bool` (False) | Simulate user interactions (mouse movements) to avoid bot detection. | +| **`override_navigator`** | `bool` (False) | Override `navigator` properties in JS for stealth. | +| **`magic`** | `bool` (False) | Automatic handling of popups/consent banners. Experimental. | +| **`adjust_viewport_to_content`** | `bool` (False) | Resizes viewport to match page content height. | +If your page is a single-page app with repeated JS updates, set `js_only=True` in subsequent calls, plus a `session_id` for reusing the same tab. +### E) **Media Handling** +| **Parameter** | **Type / Default** | **What It Does** | +|--------------------------------------------|---------------------|-----------------------------------------------------------------------------------------------------------| +| **`screenshot`** | `bool` (False) | Capture a screenshot (base64) in `result.screenshot`. | +| **`screenshot_wait_for`** | `float or None` | Extra wait time before the screenshot. | +| **`screenshot_height_threshold`** | `int` (~20000) | If the page is taller than this, alternate screenshot strategies are used. | +| **`pdf`** | `bool` (False) | If `True`, returns a PDF in `result.pdf`. | +| **`capture_mhtml`** | `bool` (False) | If `True`, captures an MHTML snapshot of the page in `result.mhtml`. MHTML includes all page resources (CSS, images, etc.) in a single file. | +| **`image_description_min_word_threshold`** | `int` (~50) | Minimum words for an image’s alt text or description to be considered valid. | +| **`image_score_threshold`** | `int` (~3) | Filter out low-scoring images. The crawler scores images by relevance (size, context, etc.). | +| **`exclude_external_images`** | `bool` (False) | Exclude images from other domains. | +### F) **Link/Domain Handling** +| **Parameter** | **Type / Default** | **What It Does** | +|------------------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| **`exclude_social_media_domains`** | `list` (e.g. Facebook/Twitter) | A default list can be extended. Any link to these domains is removed from final output. | +| **`exclude_external_links`** | `bool` (False) | Removes all links pointing outside the current domain. | +| **`exclude_social_media_links`** | `bool` (False) | Strips links specifically to social sites (like Facebook or Twitter). | +| **`exclude_domains`** | `list` ([]) | Provide a custom list of domains to exclude (like `["ads.com", "trackers.io"]`). | +| **`preserve_https_for_internal_links`** | `bool` (False) | If `True`, preserves HTTPS scheme for internal links even when the server redirects to HTTP. Useful for security-conscious crawling. | +### G) **Debug & Logging** +| **Parameter** | **Type / Default** | **What It Does** | +|----------------|--------------------|---------------------------------------------------------------------------| +| **`verbose`** | `bool` (True) | Prints logs detailing each step of crawling, interactions, or errors. | +| **`log_console`** | `bool` (False) | Logs the page’s JavaScript console output if you want deeper JS debugging.| +### H) **Virtual Scroll Configuration** +| **Parameter** | **Type / Default** | **What It Does** | +|------------------------------|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| **`virtual_scroll_config`** | `VirtualScrollConfig or dict` (None) | Configuration for handling virtualized scrolling on sites like Twitter/Instagram where content is replaced rather than appended. | +When sites use virtual scrolling (content replaced as you scroll), use `VirtualScrollConfig`: +```python +from crawl4ai import VirtualScrollConfig + +virtual_config = VirtualScrollConfig( + container_selector="#timeline", # CSS selector for scrollable container + scroll_count=30, # Number of times to scroll + scroll_by="container_height", # How much to scroll: "container_height", "page_height", or pixels (e.g. 500) + wait_after_scroll=0.5 # Seconds to wait after each scroll for content to load +) + +config = CrawlerRunConfig( + virtual_scroll_config=virtual_config +) +``` +**VirtualScrollConfig Parameters:** +| **Parameter** | **Type / Default** | **What It Does** | +|------------------------|---------------------------|-------------------------------------------------------------------------------------------| +| **`container_selector`** | `str` (required) | CSS selector for the scrollable container (e.g., `"#feed"`, `".timeline"`) | +| **`scroll_count`** | `int` (10) | Maximum number of scrolls to perform | +| **`scroll_by`** | `str or int` ("container_height") | Scroll amount: `"container_height"`, `"page_height"`, or pixels (e.g., `500`) | +| **`wait_after_scroll`** | `float` (0.5) | Time in seconds to wait after each scroll for new content to load | +- Use `virtual_scroll_config` when content is **replaced** during scroll (Twitter, Instagram) +- Use `scan_full_page` when content is **appended** during scroll (traditional infinite scroll) +### I) **URL Matching Configuration** +| **Parameter** | **Type / Default** | **What It Does** | +|------------------------|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| **`url_matcher`** | `UrlMatcher` (None) | Pattern(s) to match URLs against. Can be: string (glob), function, or list of mixed types. **None means match ALL URLs** | +| **`match_mode`** | `MatchMode` (MatchMode.OR) | How to combine multiple matchers in a list: `MatchMode.OR` (any match) or `MatchMode.AND` (all must match) | +The `url_matcher` parameter enables URL-specific configurations when used with `arun_many()`: +```python +from crawl4ai import CrawlerRunConfig, MatchMode +from crawl4ai.processors.pdf import PDFContentScrapingStrategy +from crawl4ai.extraction_strategy import JsonCssExtractionStrategy + +# Simple string pattern (glob-style) +pdf_config = CrawlerRunConfig( + url_matcher="*.pdf", + scraping_strategy=PDFContentScrapingStrategy() +) + +# Multiple patterns with OR logic (default) +blog_config = CrawlerRunConfig( + url_matcher=["*/blog/*", "*/article/*", "*/news/*"], + match_mode=MatchMode.OR # Any pattern matches +) + +# Function matcher +api_config = CrawlerRunConfig( + url_matcher=lambda url: 'api' in url or url.endswith('.json'), + # Other settings like extraction_strategy +) + +# Mixed: String + Function with AND logic +complex_config = CrawlerRunConfig( + url_matcher=[ + lambda url: url.startswith('https://'), # Must be HTTPS + "*.org/*", # Must be .org domain + lambda url: 'docs' in url # Must contain 'docs' + ], + match_mode=MatchMode.AND # ALL conditions must match +) + +# Combined patterns and functions with AND logic +secure_docs = CrawlerRunConfig( + url_matcher=["https://*", lambda url: '.doc' in url], + match_mode=MatchMode.AND # Must be HTTPS AND contain .doc +) + +# Default config - matches ALL URLs +default_config = CrawlerRunConfig() # No url_matcher = matches everything +``` +**UrlMatcher Types:** +- **None (default)**: When `url_matcher` is None or not set, the config matches ALL URLs +- **String patterns**: Glob-style patterns like `"*.pdf"`, `"*/api/*"`, `"https://*.example.com/*"` +- **Functions**: `lambda url: bool` - Custom logic for complex matching +- **Lists**: Mix strings and functions, combined with `MatchMode.OR` or `MatchMode.AND` +**Important Behavior:** +- When passing a list of configs to `arun_many()`, URLs are matched against each config's `url_matcher` in order. First match wins! +- If no config matches a URL and there's no default config (one without `url_matcher`), the URL will fail with "No matching configuration found" +Both `BrowserConfig` and `CrawlerRunConfig` provide a `clone()` method to create modified copies: +```python +# Create a base configuration +base_config = CrawlerRunConfig( + cache_mode=CacheMode.ENABLED, + word_count_threshold=200 +) + +# Create variations using clone() +stream_config = base_config.clone(stream=True) +no_cache_config = base_config.clone( + cache_mode=CacheMode.BYPASS, + stream=True +) +``` +The `clone()` method is particularly useful when you need slightly different configurations for different use cases, without modifying the original config. +## 2.3 Example Usage +```python +import asyncio +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode + +async def main(): + # Configure the browser + browser_cfg = BrowserConfig( + headless=False, + viewport_width=1280, + viewport_height=720, + proxy="http://user:pass@myproxy:8080", + text_mode=True + ) + + # Configure the run + run_cfg = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + session_id="my_session", + css_selector="main.article", + excluded_tags=["script", "style"], + exclude_external_links=True, + wait_for="css:.article-loaded", + screenshot=True, + stream=True + ) + + async with AsyncWebCrawler(config=browser_cfg) as crawler: + result = await crawler.arun( + url="https://example.com/news", + config=run_cfg + ) + if result.success: + print("Final cleaned_html length:", len(result.cleaned_html)) + if result.screenshot: + print("Screenshot captured (base64, length):", len(result.screenshot)) + else: + print("Crawl failed:", result.error_message) + +if __name__ == "__main__": + asyncio.run(main()) +``` +## 2.4 Compliance & Ethics +| **Parameter** | **Type / Default** | **What It Does** | +|-----------------------|-------------------------|----------------------------------------------------------------------------------------------------------------------| +| **`check_robots_txt`**| `bool` (False) | When True, checks and respects robots.txt rules before crawling. Uses efficient caching with SQLite backend. | +| **`user_agent`** | `str` (None) | User agent string to identify your crawler. Used for robots.txt checking when enabled. | +```python +run_config = CrawlerRunConfig( + check_robots_txt=True, # Enable robots.txt compliance + user_agent="MyBot/1.0" # Identify your crawler +) +``` +# 3. **LLMConfig** - Setting up LLM providers +1. LLMExtractionStrategy +2. LLMContentFilter +3. JsonCssExtractionStrategy.generate_schema +4. JsonXPathExtractionStrategy.generate_schema +## 3.1 Parameters +| **Parameter** | **Type / Default** | **What It Does** | +|-----------------------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| +| **`provider`** | `"ollama/llama3","groq/llama3-70b-8192","groq/llama3-8b-8192", "openai/gpt-4o-mini" ,"openai/gpt-4o","openai/o1-mini","openai/o1-preview","openai/o3-mini","openai/o3-mini-high","anthropic/claude-3-haiku-20240307","anthropic/claude-3-opus-20240229","anthropic/claude-3-sonnet-20240229","anthropic/claude-3-5-sonnet-20240620","gemini/gemini-pro","gemini/gemini-1.5-pro","gemini/gemini-2.0-flash","gemini/gemini-2.0-flash-exp","gemini/gemini-2.0-flash-lite-preview-02-05","deepseek/deepseek-chat"`
*(default: `"openai/gpt-4o-mini"`)* | Which LLM provider to use. +| **`api_token`** |1.Optional. When not provided explicitly, api_token will be read from environment variables based on provider. For example: If a gemini model is passed as provider then,`"GEMINI_API_KEY"` will be read from environment variables
2. API token of LLM provider
eg: `api_token = "gsk_1ClHGGJ7Lpn4WGybR7vNWGdyb3FY7zXEw3SCiy0BAVM9lL8CQv"`
3. Environment variable - use with prefix "env:"
eg:`api_token = "env: GROQ_API_KEY"` | API token to use for the given provider +| **`base_url`** |Optional. Custom API endpoint | If your provider has a custom endpoint +## 3.2 Example Usage +```python +llm_config = LLMConfig(provider="openai/gpt-4o-mini", api_token=os.getenv("OPENAI_API_KEY")) +``` +## 4. Putting It All Together +- **Use** `BrowserConfig` for **global** browser settings: engine, headless, proxy, user agent. +- **Use** `CrawlerRunConfig` for each crawl’s **context**: how to filter content, handle caching, wait for dynamic elements, or run JS. +- **Pass** both configs to `AsyncWebCrawler` (the `BrowserConfig`) and then to `arun()` (the `CrawlerRunConfig`). +- **Use** `LLMConfig` for LLM provider configurations that can be used across all extraction, filtering, schema generation tasks. Can be used in - `LLMExtractionStrategy`, `LLMContentFilter`, `JsonCssExtractionStrategy.generate_schema` & `JsonXPathExtractionStrategy.generate_schema` +```python +# Create a modified copy with the clone() method +stream_cfg = run_cfg.clone( + stream=True, + cache_mode=CacheMode.BYPASS +) +``` + + + +# Crawling Patterns + +# Simple Crawling +## Basic Usage +Set up a simple crawl using `BrowserConfig` and `CrawlerRunConfig`: +```python +import asyncio +from crawl4ai import AsyncWebCrawler +from crawl4ai.async_configs import BrowserConfig, CrawlerRunConfig + +async def main(): + browser_config = BrowserConfig() # Default browser configuration + run_config = CrawlerRunConfig() # Default crawl run configuration + + async with AsyncWebCrawler(config=browser_config) as crawler: + result = await crawler.arun( + url="https://example.com", + config=run_config + ) + print(result.markdown) # Print clean markdown content + +if __name__ == "__main__": + asyncio.run(main()) +``` +## Understanding the Response +The `arun()` method returns a `CrawlResult` object with several useful properties. Here's a quick overview (see [CrawlResult](../api/crawl-result.md) for complete details): +```python +config = CrawlerRunConfig( + markdown_generator=DefaultMarkdownGenerator( + content_filter=PruningContentFilter(threshold=0.6), + options={"ignore_links": True} + ) +) + +result = await crawler.arun( + url="https://example.com", + config=config +) + +# Different content formats +print(result.html) # Raw HTML +print(result.cleaned_html) # Cleaned HTML +print(result.markdown.raw_markdown) # Raw markdown from cleaned html +print(result.markdown.fit_markdown) # Most relevant content in markdown + +# Check success status +print(result.success) # True if crawl succeeded +print(result.status_code) # HTTP status code (e.g., 200, 404) + +# Access extracted media and links +print(result.media) # Dictionary of found media (images, videos, audio) +print(result.links) # Dictionary of internal and external links +``` +## Adding Basic Options +Customize your crawl using `CrawlerRunConfig`: +```python +run_config = CrawlerRunConfig( + word_count_threshold=10, # Minimum words per content block + exclude_external_links=True, # Remove external links + remove_overlay_elements=True, # Remove popups/modals + process_iframes=True # Process iframe content +) + +result = await crawler.arun( + url="https://example.com", + config=run_config +) +``` +## Handling Errors +```python +run_config = CrawlerRunConfig() +result = await crawler.arun(url="https://example.com", config=run_config) + +if not result.success: + print(f"Crawl failed: {result.error_message}") + print(f"Status code: {result.status_code}") +``` +## Logging and Debugging +Enable verbose logging in `BrowserConfig`: +```python +browser_config = BrowserConfig(verbose=True) + +async with AsyncWebCrawler(config=browser_config) as crawler: + run_config = CrawlerRunConfig() + result = await crawler.arun(url="https://example.com", config=run_config) +``` +## Complete Example +```python +import asyncio +from crawl4ai import AsyncWebCrawler +from crawl4ai.async_configs import BrowserConfig, CrawlerRunConfig, CacheMode + +async def main(): + browser_config = BrowserConfig(verbose=True) + run_config = CrawlerRunConfig( + # Content filtering + word_count_threshold=10, + excluded_tags=['form', 'header'], + exclude_external_links=True, + + # Content processing + process_iframes=True, + remove_overlay_elements=True, + + # Cache control + cache_mode=CacheMode.ENABLED # Use cache if available + ) + + async with AsyncWebCrawler(config=browser_config) as crawler: + result = await crawler.arun( + url="https://example.com", + config=run_config + ) + + if result.success: + # Print clean content + print("Content:", result.markdown[:500]) # First 500 chars + + # Process images + for image in result.media["images"]: + print(f"Found image: {image['src']}") + + # Process links + for link in result.links["internal"]: + print(f"Internal link: {link['href']}") + + else: + print(f"Crawl failed: {result.error_message}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + + + +# Content Processing + +# Markdown Generation Basics +1. How to configure the **Default Markdown Generator** +3. The difference between raw markdown (`result.markdown`) and filtered markdown (`fit_markdown`) +> - You know how to configure `CrawlerRunConfig`. +## 1. Quick Example +```python +import asyncio +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig +from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator + +async def main(): + config = CrawlerRunConfig( + markdown_generator=DefaultMarkdownGenerator() + ) + async with AsyncWebCrawler() as crawler: + result = await crawler.arun("https://example.com", config=config) + + if result.success: + print("Raw Markdown Output:\n") + print(result.markdown) # The unfiltered markdown from the page + else: + print("Crawl failed:", result.error_message) + +if __name__ == "__main__": + asyncio.run(main()) +``` +- `CrawlerRunConfig( markdown_generator = DefaultMarkdownGenerator() )` instructs Crawl4AI to convert the final HTML into markdown at the end of each crawl. +- The resulting markdown is accessible via `result.markdown`. +## 2. How Markdown Generation Works +### 2.1 HTML-to-Text Conversion (Forked & Modified) +- Preserves headings, code blocks, bullet points, etc. +- Removes extraneous tags (scripts, styles) that don’t add meaningful content. +- Can optionally generate references for links or skip them altogether. +### 2.2 Link Citations & References +By default, the generator can convert `` elements into `[text][1]` citations, then place the actual links at the bottom of the document. This is handy for research workflows that demand references in a structured manner. +### 2.3 Optional Content Filters +## 3. Configuring the Default Markdown Generator +You can tweak the output by passing an `options` dict to `DefaultMarkdownGenerator`. For example: +```python +from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig + +async def main(): + # Example: ignore all links, don't escape HTML, and wrap text at 80 characters + md_generator = DefaultMarkdownGenerator( + options={ + "ignore_links": True, + "escape_html": False, + "body_width": 80 + } + ) + + config = CrawlerRunConfig( + markdown_generator=md_generator + ) + + async with AsyncWebCrawler() as crawler: + result = await crawler.arun("https://example.com/docs", config=config) + if result.success: + print("Markdown:\n", result.markdown[:500]) # Just a snippet + else: + print("Crawl failed:", result.error_message) + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) +``` +Some commonly used `options`: +- **`ignore_links`** (bool): Whether to remove all hyperlinks in the final markdown. +- **`ignore_images`** (bool): Remove all `![image]()` references. +- **`escape_html`** (bool): Turn HTML entities into text (default is often `True`). +- **`body_width`** (int): Wrap text at N characters. `0` or `None` means no wrapping. +- **`skip_internal_links`** (bool): If `True`, omit `#localAnchors` or internal links referencing the same page. +- **`include_sup_sub`** (bool): Attempt to handle `` / `` in a more readable way. +## 4. Selecting the HTML Source for Markdown Generation +The `content_source` parameter allows you to control which HTML content is used as input for markdown generation. This gives you flexibility in how the HTML is processed before conversion to markdown. +```python +from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig + +async def main(): + # Option 1: Use the raw HTML directly from the webpage (before any processing) + raw_md_generator = DefaultMarkdownGenerator( + content_source="raw_html", + options={"ignore_links": True} + ) + + # Option 2: Use the cleaned HTML (after scraping strategy processing - default) + cleaned_md_generator = DefaultMarkdownGenerator( + content_source="cleaned_html", # This is the default + options={"ignore_links": True} + ) + + # Option 3: Use preprocessed HTML optimized for schema extraction + fit_md_generator = DefaultMarkdownGenerator( + content_source="fit_html", + options={"ignore_links": True} + ) + + # Use one of the generators in your crawler config + config = CrawlerRunConfig( + markdown_generator=raw_md_generator # Try each of the generators + ) + + async with AsyncWebCrawler() as crawler: + result = await crawler.arun("https://example.com", config=config) + if result.success: + print("Markdown:\n", result.markdown.raw_markdown[:500]) + else: + print("Crawl failed:", result.error_message) + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) +``` +### HTML Source Options +- **`"cleaned_html"`** (default): Uses the HTML after it has been processed by the scraping strategy. This HTML is typically cleaner and more focused on content, with some boilerplate removed. +- **`"raw_html"`**: Uses the original HTML directly from the webpage, before any cleaning or processing. This preserves more of the original content, but may include navigation bars, ads, footers, and other elements that might not be relevant to the main content. +- **`"fit_html"`**: Uses HTML preprocessed for schema extraction. This HTML is optimized for structured data extraction and may have certain elements simplified or removed. +### When to Use Each Option +- Use **`"cleaned_html"`** (default) for most cases where you want a balance of content preservation and noise removal. +- Use **`"raw_html"`** when you need to preserve all original content, or when the cleaning process is removing content you actually want to keep. +- Use **`"fit_html"`** when working with structured data or when you need HTML that's optimized for schema extraction. +## 5. Content Filters +### 5.1 BM25ContentFilter +```python +from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator +from crawl4ai.content_filter_strategy import BM25ContentFilter +from crawl4ai import CrawlerRunConfig + +bm25_filter = BM25ContentFilter( + user_query="machine learning", + bm25_threshold=1.2, + language="english" +) + +md_generator = DefaultMarkdownGenerator( + content_filter=bm25_filter, + options={"ignore_links": True} +) + +config = CrawlerRunConfig(markdown_generator=md_generator) +``` +- **`user_query`**: The term you want to focus on. BM25 tries to keep only content blocks relevant to that query. +- **`bm25_threshold`**: Raise it to keep fewer blocks; lower it to keep more. +- **`use_stemming`** *(default `True`)*: Whether to apply stemming to the query and content. +- **`language (str)`**: Language for stemming (default: 'english'). +### 5.2 PruningContentFilter +If you **don’t** have a specific query, or if you just want a robust β€œjunk remover,” use `PruningContentFilter`. It analyzes text density, link density, HTML structure, and known patterns (like β€œnav,” β€œfooter”) to systematically prune extraneous or repetitive sections. +```python +from crawl4ai.content_filter_strategy import PruningContentFilter + +prune_filter = PruningContentFilter( + threshold=0.5, + threshold_type="fixed", # or "dynamic" + min_word_threshold=50 +) +``` +- **`threshold`**: Score boundary. Blocks below this score get removed. +- **`threshold_type`**: + - `"fixed"`: Straight comparison (`score >= threshold` keeps the block). + - `"dynamic"`: The filter adjusts threshold in a data-driven manner. +- **`min_word_threshold`**: Discard blocks under N words as likely too short or unhelpful. +- You want a broad cleanup without a user query. +### 5.3 LLMContentFilter +```python +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, LLMConfig, DefaultMarkdownGenerator +from crawl4ai.content_filter_strategy import LLMContentFilter + +async def main(): + # Initialize LLM filter with specific instruction + filter = LLMContentFilter( + llm_config = LLMConfig(provider="openai/gpt-4o",api_token="your-api-token"), #or use environment variable + instruction=""" + Focus on extracting the core educational content. + Include: + - Key concepts and explanations + - Important code examples + - Essential technical details + Exclude: + - Navigation elements + - Sidebars + - Footer content + Format the output as clean markdown with proper code blocks and headers. + """, + chunk_token_threshold=4096, # Adjust based on your needs + verbose=True + ) + md_generator = DefaultMarkdownGenerator( + content_filter=filter, + options={"ignore_links": True} + ) + config = CrawlerRunConfig( + markdown_generator=md_generator, + ) + + async with AsyncWebCrawler() as crawler: + result = await crawler.arun("https://example.com", config=config) + print(result.markdown.fit_markdown) # Filtered markdown content +``` +- **Chunk Processing**: Handles large documents by processing them in chunks (controlled by `chunk_token_threshold`) +- **Parallel Processing**: For better performance, use smaller `chunk_token_threshold` (e.g., 2048 or 4096) to enable parallel processing of content chunks +1. **Exact Content Preservation**: +```python +filter = LLMContentFilter( + instruction=""" + Extract the main educational content while preserving its original wording and substance completely. + 1. Maintain the exact language and terminology + 2. Keep all technical explanations and examples intact + 3. Preserve the original flow and structure + 4. Remove only clearly irrelevant elements like navigation menus and ads + """, + chunk_token_threshold=4096 +) +``` +2. **Focused Content Extraction**: +```python +filter = LLMContentFilter( + instruction=""" + Focus on extracting specific types of content: + - Technical documentation + - Code examples + - API references + Reformat the content into clear, well-structured markdown + """, + chunk_token_threshold=4096 +) +``` +> **Performance Tip**: Set a smaller `chunk_token_threshold` (e.g., 2048 or 4096) to enable parallel processing of content chunks. The default value is infinity, which processes the entire content as a single chunk. +## 6. Using Fit Markdown +When a content filter is active, the library produces two forms of markdown inside `result.markdown`: +1.β€€**`raw_markdown`**: The full unfiltered markdown. +2.β€€**`fit_markdown`**: A β€œfit” version where the filter has removed or trimmed noisy segments. +```python +import asyncio +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig +from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator +from crawl4ai.content_filter_strategy import PruningContentFilter + +async def main(): + config = CrawlerRunConfig( + markdown_generator=DefaultMarkdownGenerator( + content_filter=PruningContentFilter(threshold=0.6), + options={"ignore_links": True} + ) + ) + async with AsyncWebCrawler() as crawler: + result = await crawler.arun("https://news.example.com/tech", config=config) + if result.success: + print("Raw markdown:\n", result.markdown) + + # If a filter is used, we also have .fit_markdown: + md_object = result.markdown # or your equivalent + print("Filtered markdown:\n", md_object.fit_markdown) + else: + print("Crawl failed:", result.error_message) + +if __name__ == "__main__": + asyncio.run(main()) +``` +## 7. The `MarkdownGenerationResult` Object +If your library stores detailed markdown output in an object like `MarkdownGenerationResult`, you’ll see fields such as: +- **`raw_markdown`**: The direct HTML-to-markdown transformation (no filtering). +- **`markdown_with_citations`**: A version that moves links to reference-style footnotes. +- **`references_markdown`**: A separate string or section containing the gathered references. +- **`fit_markdown`**: The filtered markdown if you used a content filter. +- **`fit_html`**: The corresponding HTML snippet used to generate `fit_markdown` (helpful for debugging or advanced usage). +```python +md_obj = result.markdown # your library’s naming may vary +print("RAW:\n", md_obj.raw_markdown) +print("CITED:\n", md_obj.markdown_with_citations) +print("REFERENCES:\n", md_obj.references_markdown) +print("FIT:\n", md_obj.fit_markdown) +``` +- You can supply `raw_markdown` to an LLM if you want the entire text. +- Or feed `fit_markdown` into a vector database to reduce token usage. +- `references_markdown` can help you keep track of link provenance. +## 8. Combining Filters (BM25 + Pruning) in Two Passes +You might want to **prune out** noisy boilerplate first (with `PruningContentFilter`), and then **rank what’s left** against a user query (with `BM25ContentFilter`). You don’t have to crawl the page twice. Instead: +1.β€€**First pass**: Apply `PruningContentFilter` directly to the raw HTML from `result.html` (the crawler’s downloaded HTML). +2.β€€**Second pass**: Take the pruned HTML (or text) from step 1, and feed it into `BM25ContentFilter`, focusing on a user query. +### Two-Pass Example +```python +import asyncio +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig +from crawl4ai.content_filter_strategy import PruningContentFilter, BM25ContentFilter +from bs4 import BeautifulSoup + +async def main(): + # 1. Crawl with minimal or no markdown generator, just get raw HTML + config = CrawlerRunConfig( + # If you only want raw HTML, you can skip passing a markdown_generator + # or provide one but focus on .html in this example + ) + + async with AsyncWebCrawler() as crawler: + result = await crawler.arun("https://example.com/tech-article", config=config) + + if not result.success or not result.html: + print("Crawl failed or no HTML content.") + return + + raw_html = result.html + + # 2. First pass: PruningContentFilter on raw HTML + pruning_filter = PruningContentFilter(threshold=0.5, min_word_threshold=50) + + # filter_content returns a list of "text chunks" or cleaned HTML sections + pruned_chunks = pruning_filter.filter_content(raw_html) + # This list is basically pruned content blocks, presumably in HTML or text form + + # For demonstration, let's combine these chunks back into a single HTML-like string + # or you could do further processing. It's up to your pipeline design. + pruned_html = "\n".join(pruned_chunks) + + # 3. Second pass: BM25ContentFilter with a user query + bm25_filter = BM25ContentFilter( + user_query="machine learning", + bm25_threshold=1.2, + language="english" + ) + + # returns a list of text chunks + bm25_chunks = bm25_filter.filter_content(pruned_html) + + if not bm25_chunks: + print("Nothing matched the BM25 query after pruning.") + return + + # 4. Combine or display final results + final_text = "\n---\n".join(bm25_chunks) + + print("==== PRUNED OUTPUT (first pass) ====") + print(pruned_html[:500], "... (truncated)") # preview + + print("\n==== BM25 OUTPUT (second pass) ====") + print(final_text[:500], "... (truncated)") + +if __name__ == "__main__": + asyncio.run(main()) +``` +### What’s Happening? +1.β€€**Raw HTML**: We crawl once and store the raw HTML in `result.html`. +4.β€€**BM25ContentFilter**: We feed the pruned string into `BM25ContentFilter` with a user query. This second pass further narrows the content to chunks relevant to β€œmachine learning.” +**No Re-Crawling**: We used `raw_html` from the first pass, so there’s no need to run `arun()` againβ€”**no second network request**. +### Tips & Variations +- **Plain Text vs. HTML**: If your pruned output is mostly text, BM25 can still handle it; just keep in mind it expects a valid string input. If you supply partial HTML (like `"

some text

"`), it will parse it as HTML. +- **Adjust Thresholds**: If you see too much or too little text in step one, tweak `threshold=0.5` or `min_word_threshold=50`. Similarly, `bm25_threshold=1.2` can be raised/lowered for more or fewer chunks in step two. +### One-Pass Combination? +## 9. Common Pitfalls & Tips +1.β€€**No Markdown Output?** +2.β€€**Performance Considerations** + - Very large pages with multiple filters can be slower. Consider `cache_mode` to avoid re-downloading. +3.β€€**Take Advantage of `fit_markdown`** +4.β€€**Adjusting `html2text` Options** + - If you see lots of raw HTML slipping into the text, turn on `escape_html`. + - If code blocks look messy, experiment with `mark_code` or `handle_code_in_pre`. +## 10. Summary & Next Steps +- Configure the **DefaultMarkdownGenerator** with HTML-to-text options. +- Select different HTML sources using the `content_source` parameter. +- Distinguish between raw and filtered markdown (`fit_markdown`). +- Leverage the `MarkdownGenerationResult` object to handle different forms of output (citations, references, etc.). + + +# Fit Markdown with Pruning & BM25 +## 1. How β€œFit Markdown” Works +### 1.1 The `content_filter` +In **`CrawlerRunConfig`**, you can specify a **`content_filter`** to shape how content is pruned or ranked before final markdown generation. A filter’s logic is applied **before** or **during** the HTMLβ†’Markdown process, producing: +- **`result.markdown.raw_markdown`** (unfiltered) +- **`result.markdown.fit_markdown`** (filtered or β€œfit” version) +- **`result.markdown.fit_html`** (the corresponding HTML snippet that produced `fit_markdown`) +### 1.2 Common Filters +## 2. PruningContentFilter +### 2.1 Usage Example +```python +import asyncio +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig +from crawl4ai.content_filter_strategy import PruningContentFilter +from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator + +async def main(): + # Step 1: Create a pruning filter + prune_filter = PruningContentFilter( + # Lower β†’ more content retained, higher β†’ more content pruned + threshold=0.45, + # "fixed" or "dynamic" + threshold_type="dynamic", + # Ignore nodes with <5 words + min_word_threshold=5 + ) + + # Step 2: Insert it into a Markdown Generator + md_generator = DefaultMarkdownGenerator(content_filter=prune_filter) + + # Step 3: Pass it to CrawlerRunConfig + config = CrawlerRunConfig( + markdown_generator=md_generator + ) + + async with AsyncWebCrawler() as crawler: + result = await crawler.arun( + url="https://news.ycombinator.com", + config=config + ) + + if result.success: + # 'fit_markdown' is your pruned content, focusing on "denser" text + print("Raw Markdown length:", len(result.markdown.raw_markdown)) + print("Fit Markdown length:", len(result.markdown.fit_markdown)) + else: + print("Error:", result.error_message) + +if __name__ == "__main__": + asyncio.run(main()) +``` +### 2.2 Key Parameters +- **`min_word_threshold`** (int): If a block has fewer words than this, it’s pruned. +- **`threshold_type`** (str): + - `"fixed"` β†’ each node must exceed `threshold` (0–1). + - `"dynamic"` β†’ node scoring adjusts according to tag type, text/link density, etc. +- **`threshold`** (float, default ~0.48): The base or β€œanchor” cutoff. +- **Link density** – Penalizes sections that are mostly links. +- **Tag importance** – e.g., an `
` or `

` might be more important than a `

`. +## 3. BM25ContentFilter +### 3.1 Usage Example +```python +import asyncio +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig +from crawl4ai.content_filter_strategy import BM25ContentFilter +from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator + +async def main(): + # 1) A BM25 filter with a user query + bm25_filter = BM25ContentFilter( + user_query="startup fundraising tips", + # Adjust for stricter or looser results + bm25_threshold=1.2 + ) + + # 2) Insert into a Markdown Generator + md_generator = DefaultMarkdownGenerator(content_filter=bm25_filter) + + # 3) Pass to crawler config + config = CrawlerRunConfig( + markdown_generator=md_generator + ) + + async with AsyncWebCrawler() as crawler: + result = await crawler.arun( + url="https://news.ycombinator.com", + config=config + ) + if result.success: + print("Fit Markdown (BM25 query-based):") + print(result.markdown.fit_markdown) + else: + print("Error:", result.error_message) + +if __name__ == "__main__": + asyncio.run(main()) +``` +### 3.2 Parameters +- **`user_query`** (str, optional): E.g. `"machine learning"`. If blank, the filter tries to glean a query from page metadata. +- **`bm25_threshold`** (float, default 1.0): + - Higher β†’ fewer chunks but more relevant. + - Lower β†’ more inclusive. +> In more advanced scenarios, you might see parameters like `language`, `case_sensitive`, or `priority_tags` to refine how text is tokenized or weighted. +## 4. Accessing the β€œFit” Output +After the crawl, your β€œfit” content is found in **`result.markdown.fit_markdown`**. +```python +fit_md = result.markdown.fit_markdown +fit_html = result.markdown.fit_html +``` +If the content filter is **BM25**, you might see additional logic or references in `fit_markdown` that highlight relevant segments. If it’s **Pruning**, the text is typically well-cleaned but not necessarily matched to a query. +## 5. Code Patterns Recap +### 5.1 Pruning +```python +prune_filter = PruningContentFilter( + threshold=0.5, + threshold_type="fixed", + min_word_threshold=10 +) +md_generator = DefaultMarkdownGenerator(content_filter=prune_filter) +config = CrawlerRunConfig(markdown_generator=md_generator) +``` +### 5.2 BM25 +```python +bm25_filter = BM25ContentFilter( + user_query="health benefits fruit", + bm25_threshold=1.2 +) +md_generator = DefaultMarkdownGenerator(content_filter=bm25_filter) +config = CrawlerRunConfig(markdown_generator=md_generator) +``` +## 6. Combining with β€œword_count_threshold” & Exclusions +```python +config = CrawlerRunConfig( + word_count_threshold=10, + excluded_tags=["nav", "footer", "header"], + exclude_external_links=True, + markdown_generator=DefaultMarkdownGenerator( + content_filter=PruningContentFilter(threshold=0.5) + ) +) +``` +1. The crawler’s `excluded_tags` are removed from the HTML first. +3. The final β€œfit” content is generated in `result.markdown.fit_markdown`. +## 7. Custom Filters +If you need a different approach (like a specialized ML model or site-specific heuristics), you can create a new class inheriting from `RelevantContentFilter` and implement `filter_content(html)`. Then inject it into your **markdown generator**: +```python +from crawl4ai.content_filter_strategy import RelevantContentFilter + +class MyCustomFilter(RelevantContentFilter): + def filter_content(self, html, min_word_threshold=None): + # parse HTML, implement custom logic + return [block for block in ... if ... some condition...] + +``` +1. Subclass `RelevantContentFilter`. +2. Implement `filter_content(...)`. +3. Use it in your `DefaultMarkdownGenerator(content_filter=MyCustomFilter(...))`. +## 8. Final Thoughts +- **Summaries**: Quickly get the important text from a cluttered page. +- **Search**: Combine with **BM25** to produce content relevant to a query. +- **BM25ContentFilter**: Perfect for query-based extraction or searching. +- Combine with **`excluded_tags`, `exclude_external_links`, `word_count_threshold`** to refine your final β€œfit” text. +- Fit markdown ends up in **`result.markdown.fit_markdown`**; eventually **`result.markdown.fit_markdown`** in future versions. +- Last Updated: 2025-01-01 + + +# Content Selection +Crawl4AI provides multiple ways to **select**, **filter**, and **refine** the content from your crawls. Whether you need to target a specific CSS region, exclude entire tags, filter out external links, or remove certain domains and images, **`CrawlerRunConfig`** offers a wide range of parameters. +## 1. CSS-Based Selection +There are two ways to select content from a page: using `css_selector` or the more flexible `target_elements`. +### 1.1 Using `css_selector` +A straightforward way to **limit** your crawl results to a certain region of the page is **`css_selector`** in **`CrawlerRunConfig`**: +```python +import asyncio +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig + +async def main(): + config = CrawlerRunConfig( + # e.g., first 30 items from Hacker News + css_selector=".athing:nth-child(-n+30)" + ) + async with AsyncWebCrawler() as crawler: + result = await crawler.arun( + url="https://news.ycombinator.com/newest", + config=config + ) + print("Partial HTML length:", len(result.cleaned_html)) + +if __name__ == "__main__": + asyncio.run(main()) +``` +**Result**: Only elements matching that selector remain in `result.cleaned_html`. +### 1.2 Using `target_elements` +The `target_elements` parameter provides more flexibility by allowing you to target **multiple elements** for content extraction while preserving the entire page context for other features: +```python +import asyncio +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig + +async def main(): + config = CrawlerRunConfig( + # Target article body and sidebar, but not other content + target_elements=["article.main-content", "aside.sidebar"] + ) + async with AsyncWebCrawler() as crawler: + result = await crawler.arun( + url="https://example.com/blog-post", + config=config + ) + print("Markdown focused on target elements") + print("Links from entire page still available:", len(result.links.get("internal", []))) + +if __name__ == "__main__": + asyncio.run(main()) +``` +**Key difference**: With `target_elements`, the markdown generation and structural data extraction focus on those elements, but other page elements (like links, images, and tables) are still extracted from the entire page. This gives you fine-grained control over what appears in your markdown content while preserving full page context for link analysis and media collection. +## 2. Content Filtering & Exclusions +### 2.1 Basic Overview +```python +config = CrawlerRunConfig( + # Content thresholds + word_count_threshold=10, # Minimum words per block + + # Tag exclusions + excluded_tags=['form', 'header', 'footer', 'nav'], + + # Link filtering + exclude_external_links=True, + exclude_social_media_links=True, + # Block entire domains + exclude_domains=["adtrackers.com", "spammynews.org"], + exclude_social_media_domains=["facebook.com", "twitter.com"], + + # Media filtering + exclude_external_images=True +) +``` +- **`word_count_threshold`**: Ignores text blocks under X words. Helps skip trivial blocks like short nav or disclaimers. +- **`excluded_tags`**: Removes entire tags (``, `
`, `