<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Surendra Tamang</title><description>Data engineering, web scraping at scale, antibot reverse engineering, and Django. Field notes from production.</description><link>https://tamangsurendra.com.np/</link><language>en</language><item><title>Scrapy, part 5: deploying to production</title><link>https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-5/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-5/</guid><description>Docker, Kubernetes, CI/CD, and monitoring. What it takes to keep a scraper fleet running while you sleep.</description><pubDate>Wed, 25 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Scrapy Part 5: Production Deployment&lt;/h1&gt;
&lt;p&gt;Final part of the series. The scraper works; now it has to run unattended. This part covers packaging it with a multi-stage Docker build, running it on Kubernetes with autoscaling, wiring up a CI/CD pipeline with automated tests, monitoring it with Prometheus and Grafana, and hardening it for production.&lt;/p&gt;
&lt;h2&gt;Docker Containerization&lt;/h2&gt;
&lt;h3&gt;Multi-Stage Dockerfile&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# webscraper/Dockerfile
# Multi-stage build for optimized production image

# Build stage
FROM python:3.11-slim as builder

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Install system dependencies
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \
    gcc \
    g++ \
    libxml2-dev \
    libxslt-dev \
    libffi-dev \
    libssl-dev \
    build-essential \
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

# Create and activate virtual environment
RUN python -m venv /opt/venv
ENV PATH=&amp;quot;/opt/venv/bin:$PATH&amp;quot;

# Install Python dependencies
COPY requirements.txt .
RUN pip install --upgrade pip &amp;amp;&amp;amp; \
    pip install -r requirements.txt

# Production stage
FROM python:3.11-slim as production

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH=&amp;quot;/opt/venv/bin:$PATH&amp;quot;

# Create non-root user
RUN groupadd -r scrapy &amp;amp;&amp;amp; useradd -r -g scrapy scrapy

# Install runtime dependencies
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \
    curl \
    ca-certificates \
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

# Copy virtual environment from builder stage
COPY --from=builder /opt/venv /opt/venv

# Create application directory
WORKDIR /app

# Copy application code
COPY --chown=scrapy:scrapy . .

# Create necessary directories
RUN mkdir -p /app/logs /app/data /app/exports &amp;amp;&amp;amp; \
    chown -R scrapy:scrapy /app

# Switch to non-root user
USER scrapy

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD curl -f http://localhost:6023/ || exit 1

# Default command
CMD [&amp;quot;scrapy&amp;quot;, &amp;quot;list&amp;quot;]

# Development stage
FROM production as development

USER root

# Install development dependencies
RUN pip install pytest pytest-cov black flake8 mypy

# Install debugging tools
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \
    vim \
    htop \
    net-tools \
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

USER scrapy

# Override default command for development
CMD [&amp;quot;tail&amp;quot;, &amp;quot;-f&amp;quot;, &amp;quot;/dev/null&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Docker Compose for Development&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# docker-compose.yml
version: &amp;#39;3.8&amp;#39;

services:
  # Main scraper service
  scraper:
    build:
      context: .
      target: development
      dockerfile: Dockerfile
    volumes:
      - .:/app
      - ./data:/app/data
      - ./logs:/app/logs
    environment:
      - SCRAPY_SETTINGS_MODULE=webscraper.settings.development
      - REDIS_URL=redis://redis:6379/0
      - MONGO_URI=mongodb://mongo:27017/scrapy_dev
      - POSTGRES_HOST=postgres
      - POSTGRES_DB=scrapy_dev
      - POSTGRES_USER=scrapy
      - POSTGRES_PASSWORD=scrapy_password
    depends_on:
      - redis
      - mongo
      - postgres
    networks:
      - scrapy-network

  # Redis for distributed scraping
  redis:
    image: redis:7-alpine
    ports:
      - &amp;quot;6379:6379&amp;quot;
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes
    networks:
      - scrapy-network

  # MongoDB for document storage
  mongo:
    image: mongo:6
    ports:
      - &amp;quot;27017:27017&amp;quot;
    volumes:
      - mongo-data:/data/db
    environment:
      - MONGO_INITDB_ROOT_USERNAME=admin
      - MONGO_INITDB_ROOT_PASSWORD=admin_password
    networks:
      - scrapy-network

  # PostgreSQL for structured data
  postgres:
    image: postgres:15
    ports:
      - &amp;quot;5432:5432&amp;quot;
    volumes:
      - postgres-data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=scrapy_dev
      - POSTGRES_USER=scrapy
      - POSTGRES_PASSWORD=scrapy_password
    networks:
      - scrapy-network

  # Elasticsearch for search and analytics
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.8.0
    ports:
      - &amp;quot;9200:9200&amp;quot;
    volumes:
      - es-data:/usr/share/elasticsearch/data
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - &amp;quot;ES_JAVA_OPTS=-Xms512m -Xmx512m&amp;quot;
    networks:
      - scrapy-network

  # Kibana for data visualization
  kibana:
    image: docker.elastic.co/kibana/kibana:8.8.0
    ports:
      - &amp;quot;5601:5601&amp;quot;
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    depends_on:
      - elasticsearch
    networks:
      - scrapy-network

  # Prometheus for monitoring
  prometheus:
    image: prom/prometheus:latest
    ports:
      - &amp;quot;9090:9090&amp;quot;
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus-data:/prometheus
    command:
      - &amp;#39;--config.file=/etc/prometheus/prometheus.yml&amp;#39;
      - &amp;#39;--storage.tsdb.path=/prometheus&amp;#39;
      - &amp;#39;--web.console.libraries=/etc/prometheus/console_libraries&amp;#39;
      - &amp;#39;--web.console.templates=/etc/prometheus/consoles&amp;#39;
    networks:
      - scrapy-network

  # Grafana for dashboards
  grafana:
    image: grafana/grafana:latest
    ports:
      - &amp;quot;3000:3000&amp;quot;
    volumes:
      - grafana-data:/var/lib/grafana
      - ./monitoring/grafana:/etc/grafana/provisioning
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    depends_on:
      - prometheus
    networks:
      - scrapy-network

  # Splash for JavaScript rendering
  splash:
    image: scrapinghub/splash:latest
    ports:
      - &amp;quot;8050:8050&amp;quot;
    command: --max-timeout=3600 --slots=5
    networks:
      - scrapy-network

volumes:
  redis-data:
  mongo-data:
  postgres-data:
  es-data:
  prometheus-data:
  grafana-data:

networks:
  scrapy-network:
    driver: bridge
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Production Docker Compose&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# docker-compose.prod.yml
version: &amp;#39;3.8&amp;#39;

services:
  # Load balancer
  nginx:
    image: nginx:alpine
    ports:
      - &amp;quot;80:80&amp;quot;
      - &amp;quot;443:443&amp;quot;
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/ssl:/etc/nginx/ssl
    depends_on:
      - scraper-coordinator
    networks:
      - scrapy-network

  # Coordinator service
  scraper-coordinator:
    build:
      context: .
      target: production
    command: [&amp;quot;python&amp;quot;, &amp;quot;scripts/coordinator.py&amp;quot;]
    environment:
      - SCRAPY_SETTINGS_MODULE=webscraper.settings.production
      - REDIS_URL=redis://redis-cluster:6379/0
      - SENTRY_DSN=${SENTRY_DSN}
    deploy:
      replicas: 1
      resources:
        limits:
          cpus: &amp;#39;0.5&amp;#39;
          memory: 512M
        reservations:
          cpus: &amp;#39;0.25&amp;#39;
          memory: 256M
    depends_on:
      - redis-cluster
    networks:
      - scrapy-network

  # Worker services
  scraper-worker:
    build:
      context: .
      target: production
    command: [&amp;quot;python&amp;quot;, &amp;quot;scripts/worker.py&amp;quot;]
    environment:
      - SCRAPY_SETTINGS_MODULE=webscraper.settings.production
      - REDIS_URL=redis://redis-cluster:6379/0
      - WORKER_ID=${HOSTNAME}
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: &amp;#39;1.0&amp;#39;
          memory: 1G
        reservations:
          cpus: &amp;#39;0.5&amp;#39;
          memory: 512M
    depends_on:
      - redis-cluster
      - scraper-coordinator
    networks:
      - scrapy-network

  # Redis cluster
  redis-cluster:
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-prod-data:/data
    deploy:
      resources:
        limits:
          cpus: &amp;#39;0.5&amp;#39;
          memory: 512M
    networks:
      - scrapy-network

  # Monitoring stack
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./monitoring/prometheus.prod.yml:/etc/prometheus/prometheus.yml
      - prometheus-prod-data:/prometheus
    deploy:
      resources:
        limits:
          cpus: &amp;#39;0.3&amp;#39;
          memory: 256M
    networks:
      - scrapy-network

  grafana:
    image: grafana/grafana:latest
    volumes:
      - grafana-prod-data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
      - GF_INSTALL_PLUGINS=grafana-piechart-panel
    deploy:
      resources:
        limits:
          cpus: &amp;#39;0.3&amp;#39;
          memory: 256M
    networks:
      - scrapy-network

volumes:
  redis-prod-data:
  prometheus-prod-data:
  grafana-prod-data:

networks:
  scrapy-network:
    driver: overlay
    attachable: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Kubernetes Deployment&lt;/h2&gt;
&lt;h3&gt;Kubernetes Manifests&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: scrapy-production
  labels:
    name: scrapy-production

---
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: scrapy-config
  namespace: scrapy-production
data:
  SCRAPY_SETTINGS_MODULE: &amp;quot;webscraper.settings.production&amp;quot;
  REDIS_URL: &amp;quot;redis://redis-service:6379/0&amp;quot;
  LOG_LEVEL: &amp;quot;INFO&amp;quot;
  CONCURRENT_REQUESTS: &amp;quot;16&amp;quot;
  DOWNLOAD_DELAY: &amp;quot;1&amp;quot;

---
# k8s/secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: scrapy-secrets
  namespace: scrapy-production
type: Opaque
data:
  # Base64 encoded values
  POSTGRES_PASSWORD: c2NyYXB5X3Bhc3N3b3Jk  # scrapy_password
  MONGO_PASSWORD: bW9uZ29fcGFzc3dvcmQ=      # mongo_password
  SENTRY_DSN: aHR0cHM6Ly9zZW50cnkuaW8=    # https://sentry.io

---
# k8s/redis-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: scrapy-production
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
      - name: redis
        image: redis:7-alpine
        ports:
        - containerPort: 6379
        command: [&amp;quot;redis-server&amp;quot;]
        args: [&amp;quot;--appendonly&amp;quot;, &amp;quot;yes&amp;quot;, &amp;quot;--maxmemory&amp;quot;, &amp;quot;256mb&amp;quot;]
        resources:
          requests:
            memory: &amp;quot;128Mi&amp;quot;
            cpu: &amp;quot;100m&amp;quot;
          limits:
            memory: &amp;quot;512Mi&amp;quot;
            cpu: &amp;quot;500m&amp;quot;
        volumeMounts:
        - name: redis-storage
          mountPath: /data
      volumes:
      - name: redis-storage
        persistentVolumeClaim:
          claimName: redis-pvc

---
apiVersion: v1
kind: Service
metadata:
  name: redis-service
  namespace: scrapy-production
spec:
  selector:
    app: redis
  ports:
  - port: 6379
    targetPort: 6379
  type: ClusterIP

---
# k8s/coordinator-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: scraper-coordinator
  namespace: scrapy-production
spec:
  replicas: 1
  selector:
    matchLabels:
      app: scraper-coordinator
  template:
    metadata:
      labels:
        app: scraper-coordinator
    spec:
      containers:
      - name: coordinator
        image: your-registry/webscraper:latest
        command: [&amp;quot;python&amp;quot;, &amp;quot;scripts/coordinator.py&amp;quot;]
        envFrom:
        - configMapRef:
            name: scrapy-config
        - secretRef:
            name: scrapy-secrets
        resources:
          requests:
            memory: &amp;quot;256Mi&amp;quot;
            cpu: &amp;quot;200m&amp;quot;
          limits:
            memory: &amp;quot;512Mi&amp;quot;
            cpu: &amp;quot;500m&amp;quot;
        ports:
        - containerPort: 8080
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5

---
# k8s/worker-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: scraper-worker
  namespace: scrapy-production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: scraper-worker
  template:
    metadata:
      labels:
        app: scraper-worker
    spec:
      containers:
      - name: worker
        image: your-registry/webscraper:latest
        command: [&amp;quot;python&amp;quot;, &amp;quot;scripts/worker.py&amp;quot;]
        envFrom:
        - configMapRef:
            name: scrapy-config
        - secretRef:
            name: scrapy-secrets
        env:
        - name: WORKER_ID
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        resources:
          requests:
            memory: &amp;quot;512Mi&amp;quot;
            cpu: &amp;quot;300m&amp;quot;
          limits:
            memory: &amp;quot;1Gi&amp;quot;
            cpu: &amp;quot;1000m&amp;quot;
        ports:
        - containerPort: 8081
        livenessProbe:
          httpGet:
            path: /health
            port: 8081
          initialDelaySeconds: 60
          periodSeconds: 30
        readinessProbe:
          httpGet:
            path: /ready
            port: 8081
          initialDelaySeconds: 10
          periodSeconds: 10

---
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: scraper-worker-hpa
  namespace: scrapy-production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: scraper-worker
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 300
      policies:
      - type: Percent
        value: 100
        periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
      - type: Percent
        value: 10
        periodSeconds: 60

---
# k8s/cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: scraper-job
  namespace: scrapy-production
spec:
  schedule: &amp;quot;0 */6 * * *&amp;quot;  # Every 6 hours
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: scraper
            image: your-registry/webscraper:latest
            command: [&amp;quot;scrapy&amp;quot;, &amp;quot;crawl&amp;quot;, &amp;quot;ecommerce&amp;quot;]
            envFrom:
            - configMapRef:
                name: scrapy-config
            - secretRef:
                name: scrapy-secrets
            resources:
              requests:
                memory: &amp;quot;1Gi&amp;quot;
                cpu: &amp;quot;500m&amp;quot;
              limits:
                memory: &amp;quot;2Gi&amp;quot;
                cpu: &amp;quot;1000m&amp;quot;
          restartPolicy: OnFailure
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1

---
# k8s/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-pvc
  namespace: scrapy-production
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
  storageClassName: fast-ssd
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;CI/CD Pipeline&lt;/h2&gt;
&lt;h3&gt;GitHub Actions Workflow&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/ci-cd.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # Test and quality checks
  test:
    runs-on: ubuntu-latest
    services:
      redis:
        image: redis
        options: &amp;gt;-
          --health-cmd &amp;quot;redis-cli ping&amp;quot;
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379

    steps:
    - uses: actions/checkout@v4

    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: &amp;#39;3.11&amp;#39;

    - name: Cache dependencies
      uses: actions/cache@v3
      with:
        path: ~/.cache/pip
        key: ${{ runner.os }}-pip-${{ hashFiles(&amp;#39;**/requirements.txt&amp;#39;) }}

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt

    - name: Code formatting check
      run: |
        black --check --diff .

    - name: Linting
      run: |
        flake8 webscraper tests

    - name: Type checking
      run: |
        mypy webscraper

    - name: Security scan
      run: |
        bandit -r webscraper

    - name: Run tests
      run: |
        pytest tests/ --cov=webscraper --cov-report=xml --cov-report=html
      env:
        REDIS_URL: redis://localhost:6379/0

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        token: ${{ secrets.CODECOV_TOKEN }}
        file: ./coverage.xml

  # Build and push Docker image
  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
    - uses: actions/checkout@v4

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

    - name: Login to Container Registry
      uses: docker/login-action@v3
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

    - name: Extract metadata
      id: meta
      uses: docker/metadata-action@v5
      with:
        images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
        tags: |
          type=ref,event=branch
          type=ref,event=pr
          type=sha,prefix={{branch}}-
          type=raw,value=latest,enable={{is_default_branch}}

    - name: Build and push
      uses: docker/build-push-action@v5
      with:
        context: .
        target: production
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

  # Deploy to staging
  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == &amp;#39;refs/heads/develop&amp;#39;
    environment: staging

    steps:
    - uses: actions/checkout@v4

    - name: Configure kubectl
      uses: azure/k8s-set-context@v3
      with:
        method: kubeconfig
        kubeconfig: ${{ secrets.KUBE_CONFIG_STAGING }}

    - name: Deploy to staging
      run: |
        # Update image in k8s manifests
        sed -i &amp;quot;s|your-registry/webscraper:latest|${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:develop|g&amp;quot; k8s/*.yaml
        
        # Apply manifests
        kubectl apply -f k8s/ -n scrapy-staging
        
        # Wait for rollout
        kubectl rollout status deployment/scraper-coordinator -n scrapy-staging
        kubectl rollout status deployment/scraper-worker -n scrapy-staging

    - name: Run smoke tests
      run: |
        # Wait for services to be ready
        kubectl wait --for=condition=ready pod -l app=scraper-coordinator -n scrapy-staging --timeout=300s
        
        # Run basic smoke tests
        python scripts/smoke_tests.py --environment=staging

  # Deploy to production
  deploy-production:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == &amp;#39;refs/heads/main&amp;#39;
    environment: production

    steps:
    - uses: actions/checkout@v4

    - name: Configure kubectl
      uses: azure/k8s-set-context@v3
      with:
        method: kubeconfig
        kubeconfig: ${{ secrets.KUBE_CONFIG_PRODUCTION }}

    - name: Deploy to production
      run: |
        # Update image in k8s manifests
        sed -i &amp;quot;s|your-registry/webscraper:latest|${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest|g&amp;quot; k8s/*.yaml
        
        # Apply manifests with rolling update
        kubectl apply -f k8s/ -n scrapy-production
        
        # Wait for rollout
        kubectl rollout status deployment/scraper-coordinator -n scrapy-production --timeout=600s
        kubectl rollout status deployment/scraper-worker -n scrapy-production --timeout=600s

    - name: Verify deployment
      run: |
        # Check pod health
        kubectl get pods -n scrapy-production
        
        # Run production health checks
        python scripts/health_check.py --environment=production

    - name: Notify deployment
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        channel: &amp;#39;#deployments&amp;#39;
        webhook_url: ${{ secrets.SLACK_WEBHOOK }}
      if: always()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Testing Framework&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# tests/test_spiders.py
import pytest
import responses
from scrapy.http import HtmlResponse, Request
from webscraper.spiders.ecommerce_spider import EcommerceSpider
from webscraper.items import ProductItem

class TestEcommerceSpider:
    
    @pytest.fixture
    def spider(self):
        return EcommerceSpider()
    
    @pytest.fixture
    def sample_product_html(self):
        return &amp;quot;&amp;quot;&amp;quot;
        &amp;lt;html&amp;gt;
        &amp;lt;body&amp;gt;
            &amp;lt;h1 class=&amp;quot;product-title&amp;quot;&amp;gt;Test Product&amp;lt;/h1&amp;gt;
            &amp;lt;span class=&amp;quot;price-current&amp;quot;&amp;gt;$99.99&amp;lt;/span&amp;gt;
            &amp;lt;div class=&amp;quot;product-description&amp;quot;&amp;gt;
                &amp;lt;p&amp;gt;This is a test product description&amp;lt;/p&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;span class=&amp;quot;brand-name&amp;quot;&amp;gt;TestBrand&amp;lt;/span&amp;gt;
            &amp;lt;span class=&amp;quot;rating-value&amp;quot;&amp;gt;4.5&amp;lt;/span&amp;gt;
            &amp;lt;div class=&amp;quot;stock-status&amp;quot;&amp;gt;In Stock&amp;lt;/div&amp;gt;
        &amp;lt;/body&amp;gt;
        &amp;lt;/html&amp;gt;
        &amp;quot;&amp;quot;&amp;quot;
    
    def create_response(self, html, url=&amp;quot;http://test.com&amp;quot;):
        request = Request(url=url)
        return HtmlResponse(url=url, request=request, body=html.encode(&amp;#39;utf-8&amp;#39;))
    
    def test_parse_product_basic(self, spider, sample_product_html):
        &amp;quot;&amp;quot;&amp;quot;Test basic product parsing&amp;quot;&amp;quot;&amp;quot;
        response = self.create_response(sample_product_html)
        
        items = list(spider.parse_product(response))
        
        assert len(items) == 1
        item = items[0]
        
        assert item[&amp;#39;name&amp;#39;] == &amp;#39;Test Product&amp;#39;
        assert item[&amp;#39;price&amp;#39;] == 99.99
        assert item[&amp;#39;brand&amp;#39;] == &amp;#39;TestBrand&amp;#39;
        assert item[&amp;#39;rating&amp;#39;] == 4.5
    
    def test_parse_product_missing_fields(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Test handling of missing fields&amp;quot;&amp;quot;&amp;quot;
        html = &amp;quot;&amp;lt;html&amp;gt;&amp;lt;body&amp;gt;&amp;lt;h1&amp;gt;Product&amp;lt;/h1&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;&amp;quot;
        response = self.create_response(html)
        
        items = list(spider.parse_product(response))
        
        assert len(items) == 1
        item = items[0]
        assert item[&amp;#39;name&amp;#39;] == &amp;#39;Product&amp;#39;
        assert &amp;#39;price&amp;#39; not in item or item[&amp;#39;price&amp;#39;] is None
    
    @responses.activate
    def test_api_integration(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Test API integration&amp;quot;&amp;quot;&amp;quot;
        # Mock API response
        responses.add(
            responses.GET,
            &amp;#39;http://api.test.com/products&amp;#39;,
            json={&amp;#39;products&amp;#39;: [{&amp;#39;id&amp;#39;: 1, &amp;#39;name&amp;#39;: &amp;#39;API Product&amp;#39;}]},
            status=200
        )
        
        # Test API call logic
        import requests
        response = requests.get(&amp;#39;http://api.test.com/products&amp;#39;)
        assert response.status_code == 200
        assert response.json()[&amp;#39;products&amp;#39;][0][&amp;#39;name&amp;#39;] == &amp;#39;API Product&amp;#39;

# tests/test_pipelines.py
import pytest
from itemadapter import ItemAdapter
from webscraper.pipelines import AdvancedValidationPipeline, ValidationError
from webscraper.items import ProductItem

class TestValidationPipeline:
    
    @pytest.fixture
    def pipeline(self):
        return AdvancedValidationPipeline()
    
    @pytest.fixture
    def valid_item(self):
        return ProductItem({
            &amp;#39;name&amp;#39;: &amp;#39;Test Product&amp;#39;,
            &amp;#39;url&amp;#39;: &amp;#39;https://test.com/product&amp;#39;,
            &amp;#39;price&amp;#39;: 99.99,
            &amp;#39;rating&amp;#39;: 4.5,
            &amp;#39;in_stock&amp;#39;: True
        })
    
    def test_valid_item_passes(self, pipeline, valid_item):
        &amp;quot;&amp;quot;&amp;quot;Test that valid item passes validation&amp;quot;&amp;quot;&amp;quot;
        result = pipeline.process_item(valid_item, None)
        assert result is not None
        assert result[&amp;#39;validation_passed&amp;#39;] is True
    
    def test_missing_required_field_fails(self, pipeline):
        &amp;quot;&amp;quot;&amp;quot;Test that missing required fields cause validation failure&amp;quot;&amp;quot;&amp;quot;
        item = ProductItem({&amp;#39;price&amp;#39;: 99.99})
        
        with pytest.raises(ValidationError):
            pipeline.process_item(item, None)
    
    def test_invalid_price_type_fails(self, pipeline):
        &amp;quot;&amp;quot;&amp;quot;Test that invalid price type causes validation failure&amp;quot;&amp;quot;&amp;quot;
        item = ProductItem({
            &amp;#39;name&amp;#39;: &amp;#39;Test Product&amp;#39;,
            &amp;#39;url&amp;#39;: &amp;#39;https://test.com/product&amp;#39;,
            &amp;#39;price&amp;#39;: &amp;#39;invalid&amp;#39;
        })
        
        with pytest.raises(ValidationError):
            pipeline.process_item(item, None)
    
    def test_price_cleaning(self, pipeline):
        &amp;quot;&amp;quot;&amp;quot;Test price cleaning functionality&amp;quot;&amp;quot;&amp;quot;
        price_tests = [
            (&amp;#39;$99.99&amp;#39;, 99.99),
            (&amp;#39;€1,234.56&amp;#39;, 1234.56),
            (&amp;#39;1.234,56&amp;#39;, 1234.56),
            (&amp;#39;FREE&amp;#39;, None),
            (&amp;#39;&amp;#39;, None)
        ]
        
        for input_price, expected in price_tests:
            cleaned = pipeline._clean_price(input_price)
            assert cleaned == expected

# scripts/smoke_tests.py
import requests
import time
import sys
import argparse
from typing import Dict, List

class SmokeTests:
    &amp;quot;&amp;quot;&amp;quot;Basic smoke tests for deployed environment&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, environment: str):
        self.environment = environment
        self.base_urls = {
            &amp;#39;staging&amp;#39;: &amp;#39;https://staging-scraper.yourcompany.com&amp;#39;,
            &amp;#39;production&amp;#39;: &amp;#39;https://scraper.yourcompany.com&amp;#39;
        }
        self.base_url = self.base_urls[environment]
    
    def test_health_endpoints(self) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Test health endpoints&amp;quot;&amp;quot;&amp;quot;
        endpoints = [&amp;#39;/health&amp;#39;, &amp;#39;/ready&amp;#39;, &amp;#39;/metrics&amp;#39;]
        
        for endpoint in endpoints:
            try:
                response = requests.get(f&amp;quot;{self.base_url}{endpoint}&amp;quot;, timeout=10)
                if response.status_code != 200:
                    print(f&amp;quot;❌ Health check failed for {endpoint}: {response.status_code}&amp;quot;)
                    return False
                print(f&amp;quot;✅ Health check passed for {endpoint}&amp;quot;)
            except Exception as e:
                print(f&amp;quot;❌ Health check failed for {endpoint}: {e}&amp;quot;)
                return False
        
        return True
    
    def test_basic_functionality(self) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Test basic scraping functionality&amp;quot;&amp;quot;&amp;quot;
        try:
            # Trigger a test spider run
            response = requests.post(
                f&amp;quot;{self.base_url}/api/spiders/test/start&amp;quot;,
                json={&amp;#39;test_mode&amp;#39;: True},
                timeout=30
            )
            
            if response.status_code != 200:
                print(f&amp;quot;❌ Failed to start test spider: {response.status_code}&amp;quot;)
                return False
            
            job_id = response.json().get(&amp;#39;job_id&amp;#39;)
            
            # Check job status
            for _ in range(10):  # Wait up to 50 seconds
                status_response = requests.get(
                    f&amp;quot;{self.base_url}/api/jobs/{job_id}/status&amp;quot;,
                    timeout=10
                )
                
                if status_response.status_code == 200:
                    status = status_response.json().get(&amp;#39;status&amp;#39;)
                    if status == &amp;#39;completed&amp;#39;:
                        print(&amp;quot;✅ Basic functionality test passed&amp;quot;)
                        return True
                    elif status == &amp;#39;failed&amp;#39;:
                        print(&amp;quot;❌ Basic functionality test failed&amp;quot;)
                        return False
                
                time.sleep(5)
            
            print(&amp;quot;❌ Basic functionality test timed out&amp;quot;)
            return False
            
        except Exception as e:
            print(f&amp;quot;❌ Basic functionality test failed: {e}&amp;quot;)
            return False
    
    def run_all_tests(self) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Run all smoke tests&amp;quot;&amp;quot;&amp;quot;
        print(f&amp;quot;🚀 Running smoke tests for {self.environment} environment&amp;quot;)
        
        tests = [
            self.test_health_endpoints,
            self.test_basic_functionality
        ]
        
        results = []
        for test in tests:
            results.append(test())
        
        success = all(results)
        
        if success:
            print(&amp;quot;🎉 All smoke tests passed!&amp;quot;)
        else:
            print(&amp;quot;💥 Some smoke tests failed!&amp;quot;)
        
        return success

def main():
    parser = argparse.ArgumentParser(description=&amp;#39;Run smoke tests&amp;#39;)
    parser.add_argument(&amp;#39;--environment&amp;#39;, required=True, 
                       choices=[&amp;#39;staging&amp;#39;, &amp;#39;production&amp;#39;],
                       help=&amp;#39;Target environment&amp;#39;)
    args = parser.parse_args()
    
    tests = SmokeTests(args.environment)
    success = tests.run_all_tests()
    
    sys.exit(0 if success else 1)

if __name__ == &amp;quot;__main__&amp;quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Production Monitoring and Observability&lt;/h2&gt;
&lt;h3&gt;Prometheus Metrics&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/monitoring/metrics.py
from prometheus_client import Counter, Histogram, Gauge, CollectorRegistry, push_to_gateway
import time
from functools import wraps
from typing import Callable
import logging

class ScrapingMetrics:
    &amp;quot;&amp;quot;&amp;quot;Prometheus metrics for scraping operations&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, pushgateway_url: str = None):
        self.registry = CollectorRegistry()
        self.pushgateway_url = pushgateway_url
        
        # Counters
        self.requests_total = Counter(
            &amp;#39;scrapy_requests_total&amp;#39;,
            &amp;#39;Total number of requests made&amp;#39;,
            [&amp;#39;spider&amp;#39;, &amp;#39;status&amp;#39;],
            registry=self.registry
        )
        
        self.items_scraped_total = Counter(
            &amp;#39;scrapy_items_scraped_total&amp;#39;,
            &amp;#39;Total number of items scraped&amp;#39;,
            [&amp;#39;spider&amp;#39;, &amp;#39;item_type&amp;#39;],
            registry=self.registry
        )
        
        self.errors_total = Counter(
            &amp;#39;scrapy_errors_total&amp;#39;,
            &amp;#39;Total number of errors&amp;#39;,
            [&amp;#39;spider&amp;#39;, &amp;#39;error_type&amp;#39;],
            registry=self.registry
        )
        
        # Histograms
        self.response_time = Histogram(
            &amp;#39;scrapy_response_time_seconds&amp;#39;,
            &amp;#39;Response time for requests&amp;#39;,
            [&amp;#39;spider&amp;#39;, &amp;#39;domain&amp;#39;],
            registry=self.registry
        )
        
        self.item_processing_time = Histogram(
            &amp;#39;scrapy_item_processing_time_seconds&amp;#39;,
            &amp;#39;Time to process items&amp;#39;,
            [&amp;#39;spider&amp;#39;, &amp;#39;pipeline&amp;#39;],
            registry=self.registry
        )
        
        # Gauges
        self.active_requests = Gauge(
            &amp;#39;scrapy_active_requests&amp;#39;,
            &amp;#39;Number of active requests&amp;#39;,
            [&amp;#39;spider&amp;#39;],
            registry=self.registry
        )
        
        self.queue_size = Gauge(
            &amp;#39;scrapy_queue_size&amp;#39;,
            &amp;#39;Size of request queue&amp;#39;,
            [&amp;#39;spider&amp;#39;],
            registry=self.registry
        )
        
        self.memory_usage = Gauge(
            &amp;#39;scrapy_memory_usage_bytes&amp;#39;,
            &amp;#39;Memory usage in bytes&amp;#39;,
            [&amp;#39;spider&amp;#39;],
            registry=self.registry
        )
    
    def record_request(self, spider: str, status: str, response_time: float = None, domain: str = None):
        &amp;quot;&amp;quot;&amp;quot;Record request metrics&amp;quot;&amp;quot;&amp;quot;
        self.requests_total.labels(spider=spider, status=status).inc()
        
        if response_time and domain:
            self.response_time.labels(spider=spider, domain=domain).observe(response_time)
    
    def record_item(self, spider: str, item_type: str):
        &amp;quot;&amp;quot;&amp;quot;Record scraped item&amp;quot;&amp;quot;&amp;quot;
        self.items_scraped_total.labels(spider=spider, item_type=item_type).inc()
    
    def record_error(self, spider: str, error_type: str):
        &amp;quot;&amp;quot;&amp;quot;Record error&amp;quot;&amp;quot;&amp;quot;
        self.errors_total.labels(spider=spider, error_type=error_type).inc()
    
    def update_queue_size(self, spider: str, size: int):
        &amp;quot;&amp;quot;&amp;quot;Update queue size&amp;quot;&amp;quot;&amp;quot;
        self.queue_size.labels(spider=spider).set(size)
    
    def update_active_requests(self, spider: str, count: int):
        &amp;quot;&amp;quot;&amp;quot;Update active requests count&amp;quot;&amp;quot;&amp;quot;
        self.active_requests.labels(spider=spider).set(count)
    
    def update_memory_usage(self, spider: str, bytes_used: int):
        &amp;quot;&amp;quot;&amp;quot;Update memory usage&amp;quot;&amp;quot;&amp;quot;
        self.memory_usage.labels(spider=spider).set(bytes_used)
    
    def push_metrics(self, job_name: str):
        &amp;quot;&amp;quot;&amp;quot;Push metrics to Pushgateway&amp;quot;&amp;quot;&amp;quot;
        if self.pushgateway_url:
            try:
                push_to_gateway(
                    self.pushgateway_url, 
                    job=job_name, 
                    registry=self.registry
                )
            except Exception as e:
                logging.error(f&amp;quot;Failed to push metrics: {e}&amp;quot;)

def monitor_performance(metrics: ScrapingMetrics, spider_name: str):
    &amp;quot;&amp;quot;&amp;quot;Decorator to monitor function performance&amp;quot;&amp;quot;&amp;quot;
    def decorator(func: Callable) -&amp;gt; Callable:
        @wraps(func)
        def wrapper(*args, **kwargs):
            start_time = time.time()
            try:
                result = func(*args, **kwargs)
                return result
            except Exception as e:
                metrics.record_error(spider_name, type(e).__name__)
                raise
            finally:
                duration = time.time() - start_time
                metrics.item_processing_time.labels(
                    spider=spider_name, 
                    pipeline=func.__name__
                ).observe(duration)
        return wrapper
    return decorator

# Integration with Scrapy
class PrometheusStatsCollector:
    &amp;quot;&amp;quot;&amp;quot;Collect Scrapy stats and export to Prometheus&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, crawler):
        self.crawler = crawler
        self.metrics = ScrapingMetrics(
            pushgateway_url=crawler.settings.get(&amp;#39;PROMETHEUS_PUSHGATEWAY_URL&amp;#39;)
        )
        self.spider_name = None
    
    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler)
    
    def spider_opened(self, spider):
        self.spider_name = spider.name
        spider.logger.info(f&amp;quot;Prometheus metrics enabled for spider: {spider.name}&amp;quot;)
    
    def spider_closed(self, spider, reason):
        # Push final metrics
        if self.metrics.pushgateway_url:
            self.metrics.push_metrics(f&amp;quot;scrapy_{spider.name}&amp;quot;)
        
        # Log final stats
        stats = self.crawler.stats.get_stats()
        spider.logger.info(f&amp;quot;Final stats: {stats}&amp;quot;)
    
    def request_scheduled(self, request, spider):
        self.metrics.update_active_requests(
            spider.name, 
            self.crawler.stats.get_value(&amp;#39;scheduler/enqueued&amp;#39;, 0)
        )
    
    def response_received(self, response, request, spider):
        # Record response metrics
        status = str(response.status)
        domain = response.url.split(&amp;#39;/&amp;#39;)[2] if &amp;#39;://&amp;#39; in response.url else &amp;#39;unknown&amp;#39;
        
        self.metrics.record_request(
            spider=spider.name,
            status=status,
            domain=domain
        )
    
    def item_scraped(self, item, response, spider):
        # Record item metrics
        item_type = type(item).__name__
        self.metrics.record_item(spider.name, item_type)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Grafana Dashboard Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;dashboard&amp;quot;: {
    &amp;quot;id&amp;quot;: null,
    &amp;quot;title&amp;quot;: &amp;quot;Scrapy Monitoring Dashboard&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;Comprehensive monitoring for Scrapy spiders&amp;quot;,
    &amp;quot;tags&amp;quot;: [&amp;quot;scrapy&amp;quot;, &amp;quot;monitoring&amp;quot;],
    &amp;quot;timezone&amp;quot;: &amp;quot;browser&amp;quot;,
    &amp;quot;panels&amp;quot;: [
      {
        &amp;quot;id&amp;quot;: 1,
        &amp;quot;title&amp;quot;: &amp;quot;Requests per Second&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;graph&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;rate(scrapy_requests_total[5m])&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{spider}} - {{status}}&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 0, &amp;quot;y&amp;quot;: 0},
        &amp;quot;yAxes&amp;quot;: [
          {&amp;quot;label&amp;quot;: &amp;quot;Requests/sec&amp;quot;, &amp;quot;min&amp;quot;: 0}
        ]
      },
      {
        &amp;quot;id&amp;quot;: 2,
        &amp;quot;title&amp;quot;: &amp;quot;Items Scraped per Hour&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;graph&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;rate(scrapy_items_scraped_total[1h])*3600&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{spider}} - {{item_type}}&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 12, &amp;quot;y&amp;quot;: 0}
      },
      {
        &amp;quot;id&amp;quot;: 3,
        &amp;quot;title&amp;quot;: &amp;quot;Error Rate&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;graph&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;rate(scrapy_errors_total[5m]) / rate(scrapy_requests_total[5m]) * 100&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{spider}} error rate %&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 0, &amp;quot;y&amp;quot;: 8},
        &amp;quot;alert&amp;quot;: {
          &amp;quot;conditions&amp;quot;: [
            {
              &amp;quot;query&amp;quot;: {&amp;quot;queryType&amp;quot;: &amp;quot;&amp;quot;, &amp;quot;refId&amp;quot;: &amp;quot;A&amp;quot;},
              &amp;quot;reducer&amp;quot;: {&amp;quot;type&amp;quot;: &amp;quot;last&amp;quot;, &amp;quot;params&amp;quot;: []},
              &amp;quot;evaluator&amp;quot;: {&amp;quot;params&amp;quot;: [5], &amp;quot;type&amp;quot;: &amp;quot;gt&amp;quot;}
            }
          ],
          &amp;quot;executionErrorState&amp;quot;: &amp;quot;alerting&amp;quot;,
          &amp;quot;frequency&amp;quot;: &amp;quot;10s&amp;quot;,
          &amp;quot;handler&amp;quot;: 1,
          &amp;quot;name&amp;quot;: &amp;quot;High Error Rate&amp;quot;,
          &amp;quot;noDataState&amp;quot;: &amp;quot;no_data&amp;quot;
        }
      },
      {
        &amp;quot;id&amp;quot;: 4,
        &amp;quot;title&amp;quot;: &amp;quot;Response Time Distribution&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;heatmap&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;scrapy_response_time_seconds_bucket&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{le}}&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 12, &amp;quot;y&amp;quot;: 8}
      },
      {
        &amp;quot;id&amp;quot;: 5,
        &amp;quot;title&amp;quot;: &amp;quot;Memory Usage&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;graph&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;scrapy_memory_usage_bytes / 1024 / 1024&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{spider}} Memory (MB)&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 24, &amp;quot;x&amp;quot;: 0, &amp;quot;y&amp;quot;: 16}
      },
      {
        &amp;quot;id&amp;quot;: 6,
        &amp;quot;title&amp;quot;: &amp;quot;Queue Size&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;graph&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;scrapy_queue_size&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{spider}} Queue&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 0, &amp;quot;y&amp;quot;: 24}
      },
      {
        &amp;quot;id&amp;quot;: 7,
        &amp;quot;title&amp;quot;: &amp;quot;Active Requests&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;stat&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;sum(scrapy_active_requests)&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;Total Active&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 12, &amp;quot;y&amp;quot;: 24}
      }
    ],
    &amp;quot;time&amp;quot;: {&amp;quot;from&amp;quot;: &amp;quot;now-1h&amp;quot;, &amp;quot;to&amp;quot;: &amp;quot;now&amp;quot;},
    &amp;quot;refresh&amp;quot;: &amp;quot;5s&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Security and Compliance&lt;/h2&gt;
&lt;h3&gt;Security Hardening&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/security/security_middleware.py
import hmac
import hashlib
import time
from scrapy.downloadermiddlewares.useragent import UserAgentMiddleware
from scrapy.exceptions import IgnoreRequest
import logging

class SecurityMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Security middleware for production environments&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, settings):
        self.settings = settings
        self.logger = logging.getLogger(__name__)
        
        # Rate limiting
        self.rate_limits = {}
        self.last_request_time = {}
        
        # Security headers
        self.required_headers = {
            &amp;#39;User-Agent&amp;#39;: True,
            &amp;#39;Accept&amp;#39;: True,
            &amp;#39;Accept-Language&amp;#39;: True
        }
        
        # Blocked patterns
        self.blocked_patterns = [
            r&amp;#39;.*\.exe$&amp;#39;,
            r&amp;#39;.*\.zip$&amp;#39;,
            r&amp;#39;.*admin.*&amp;#39;,
            r&amp;#39;.*login.*&amp;#39;,
            r&amp;#39;.*private.*&amp;#39;
        ]
    
    def process_request(self, request, spider):
        # Validate request security
        if not self._validate_request_security(request):
            raise IgnoreRequest(&amp;quot;Request blocked by security policy&amp;quot;)
        
        # Apply rate limiting
        self._apply_rate_limiting(request, spider)
        
        # Add security headers
        self._add_security_headers(request)
        
        return None
    
    def _validate_request_security(self, request):
        &amp;quot;&amp;quot;&amp;quot;Validate request against security policies&amp;quot;&amp;quot;&amp;quot;
        url = request.url.lower()
        
        # Check blocked patterns
        import re
        for pattern in self.blocked_patterns:
            if re.match(pattern, url):
                self.logger.warning(f&amp;quot;Blocked request to: {request.url}&amp;quot;)
                return False
        
        # Check for required headers
        for header, required in self.required_headers.items():
            if required and header not in request.headers:
                self.logger.warning(f&amp;quot;Missing required header: {header}&amp;quot;)
                return False
        
        return True
    
    def _apply_rate_limiting(self, request, spider):
        &amp;quot;&amp;quot;&amp;quot;Apply rate limiting per domain&amp;quot;&amp;quot;&amp;quot;
        from urllib.parse import urlparse
        domain = urlparse(request.url).netloc
        
        current_time = time.time()
        min_delay = self.settings.getfloat(&amp;#39;SECURITY_MIN_DELAY&amp;#39;, 1.0)
        
        if domain in self.last_request_time:
            time_since_last = current_time - self.last_request_time[domain]
            if time_since_last &amp;lt; min_delay:
                sleep_time = min_delay - time_since_last
                spider.logger.debug(f&amp;quot;Security rate limiting: {sleep_time:.2f}s for {domain}&amp;quot;)
                time.sleep(sleep_time)
        
        self.last_request_time[domain] = time.time()
    
    def _add_security_headers(self, request):
        &amp;quot;&amp;quot;&amp;quot;Add security headers to requests&amp;quot;&amp;quot;&amp;quot;
        # Add timestamp for request validation
        timestamp = str(int(time.time()))
        request.headers[&amp;#39;X-Request-Timestamp&amp;#39;] = timestamp
        
        # Add security token if configured
        secret_key = self.settings.get(&amp;#39;SECURITY_SECRET_KEY&amp;#39;)
        if secret_key:
            signature = hmac.new(
                secret_key.encode(),
                f&amp;quot;{request.url}{timestamp}&amp;quot;.encode(),
                hashlib.sha256
            ).hexdigest()
            request.headers[&amp;#39;X-Security-Signature&amp;#39;] = signature

class DataEncryptionPipeline:
    &amp;quot;&amp;quot;&amp;quot;Encrypt sensitive data before storage&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, encryption_key):
        self.encryption_key = encryption_key
        self.sensitive_fields = [&amp;#39;email&amp;#39;, &amp;#39;phone&amp;#39;, &amp;#39;address&amp;#39;, &amp;#39;personal_id&amp;#39;]
    
    @classmethod
    def from_crawler(cls, crawler):
        encryption_key = crawler.settings.get(&amp;#39;ENCRYPTION_KEY&amp;#39;)
        if not encryption_key:
            raise ValueError(&amp;quot;ENCRYPTION_KEY setting is required&amp;quot;)
        return cls(encryption_key)
    
    def process_item(self, item, spider):
        from cryptography.fernet import Fernet
        
        fernet = Fernet(self.encryption_key.encode())
        
        for field in self.sensitive_fields:
            if field in item and item[field]:
                # Encrypt sensitive data
                encrypted_data = fernet.encrypt(str(item[field]).encode())
                item[f&amp;quot;{field}_encrypted&amp;quot;] = encrypted_data.decode()
                # Remove original field
                del item[field]
        
        return item

# scripts/security_audit.py
import subprocess
import json
import sys
from typing import Dict, List

class SecurityAudit:
    &amp;quot;&amp;quot;&amp;quot;Security audit for production deployment&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.issues = []
    
    def audit_dependencies(self) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Audit Python dependencies for known vulnerabilities&amp;quot;&amp;quot;&amp;quot;
        try:
            result = subprocess.run(
                [&amp;#39;safety&amp;#39;, &amp;#39;check&amp;#39;, &amp;#39;--json&amp;#39;],
                capture_output=True,
                text=True
            )
            
            if result.returncode != 0:
                vulnerabilities = json.loads(result.stdout)
                for vuln in vulnerabilities:
                    self.issues.append({
                        &amp;#39;type&amp;#39;: &amp;#39;dependency_vulnerability&amp;#39;,
                        &amp;#39;severity&amp;#39;: &amp;#39;high&amp;#39;,
                        &amp;#39;package&amp;#39;: vuln[&amp;#39;package_name&amp;#39;],
                        &amp;#39;vulnerability&amp;#39;: vuln[&amp;#39;vulnerability_id&amp;#39;],
                        &amp;#39;description&amp;#39;: vuln[&amp;#39;advisory&amp;#39;]
                    })
                return False
            
            return True
            
        except Exception as e:
            self.issues.append({
                &amp;#39;type&amp;#39;: &amp;#39;audit_error&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;medium&amp;#39;,
                &amp;#39;description&amp;#39;: f&amp;quot;Failed to audit dependencies: {e}&amp;quot;
            })
            return False
    
    def audit_docker_image(self, image_name: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Audit Docker image for security issues&amp;quot;&amp;quot;&amp;quot;
        try:
            result = subprocess.run(
                [&amp;#39;trivy&amp;#39;, &amp;#39;image&amp;#39;, &amp;#39;--format&amp;#39;, &amp;#39;json&amp;#39;, image_name],
                capture_output=True,
                text=True
            )
            
            if result.returncode == 0:
                scan_results = json.loads(result.stdout)
                
                for result in scan_results.get(&amp;#39;Results&amp;#39;, []):
                    for vuln in result.get(&amp;#39;Vulnerabilities&amp;#39;, []):
                        if vuln.get(&amp;#39;Severity&amp;#39;) in [&amp;#39;HIGH&amp;#39;, &amp;#39;CRITICAL&amp;#39;]:
                            self.issues.append({
                                &amp;#39;type&amp;#39;: &amp;#39;container_vulnerability&amp;#39;,
                                &amp;#39;severity&amp;#39;: vuln[&amp;#39;Severity&amp;#39;].lower(),
                                &amp;#39;package&amp;#39;: vuln.get(&amp;#39;PkgName&amp;#39;),
                                &amp;#39;vulnerability&amp;#39;: vuln.get(&amp;#39;VulnerabilityID&amp;#39;),
                                &amp;#39;description&amp;#39;: vuln.get(&amp;#39;Description&amp;#39;, &amp;#39;&amp;#39;)
                            })
            
            return len([i for i in self.issues if i[&amp;#39;type&amp;#39;] == &amp;#39;container_vulnerability&amp;#39;]) == 0
            
        except Exception as e:
            self.issues.append({
                &amp;#39;type&amp;#39;: &amp;#39;audit_error&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;medium&amp;#39;,
                &amp;#39;description&amp;#39;: f&amp;quot;Failed to audit Docker image: {e}&amp;quot;
            })
            return False
    
    def audit_kubernetes_config(self, config_path: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Audit Kubernetes configuration&amp;quot;&amp;quot;&amp;quot;
        security_checks = [
            self._check_non_root_user,
            self._check_resource_limits,
            self._check_security_context,
            self._check_network_policies
        ]
        
        passed = True
        for check in security_checks:
            if not check(config_path):
                passed = False
        
        return passed
    
    def _check_non_root_user(self, config_path: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Check if containers run as non-root&amp;quot;&amp;quot;&amp;quot;
        # Implementation for checking non-root user
        return True
    
    def _check_resource_limits(self, config_path: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Check if resource limits are set&amp;quot;&amp;quot;&amp;quot;
        # Implementation for checking resource limits
        return True
    
    def _check_security_context(self, config_path: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Check security context configuration&amp;quot;&amp;quot;&amp;quot;
        # Implementation for checking security context
        return True
    
    def _check_network_policies(self, config_path: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Check network policies&amp;quot;&amp;quot;&amp;quot;
        # Implementation for checking network policies
        return True
    
    def generate_report(self) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Generate security audit report&amp;quot;&amp;quot;&amp;quot;
        severity_counts = {}
        for issue in self.issues:
            severity = issue[&amp;#39;severity&amp;#39;]
            severity_counts[severity] = severity_counts.get(severity, 0) + 1
        
        return {
            &amp;#39;total_issues&amp;#39;: len(self.issues),
            &amp;#39;severity_breakdown&amp;#39;: severity_counts,
            &amp;#39;issues&amp;#39;: self.issues,
            &amp;#39;passed&amp;#39;: len(self.issues) == 0
        }
    
    def run_full_audit(self, image_name: str = None, config_path: str = None) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Run complete security audit&amp;quot;&amp;quot;&amp;quot;
        print(&amp;quot;🔒 Running security audit...&amp;quot;)
        
        # Audit dependencies
        deps_ok = self.audit_dependencies()
        print(f&amp;quot;Dependencies: {&amp;#39;✅&amp;#39; if deps_ok else &amp;#39;❌&amp;#39;}&amp;quot;)
        
        # Audit Docker image if provided
        if image_name:
            image_ok = self.audit_docker_image(image_name)
            print(f&amp;quot;Docker image: {&amp;#39;✅&amp;#39; if image_ok else &amp;#39;❌&amp;#39;}&amp;quot;)
        
        # Audit Kubernetes config if provided
        if config_path:
            k8s_ok = self.audit_kubernetes_config(config_path)
            print(f&amp;quot;Kubernetes config: {&amp;#39;✅&amp;#39; if k8s_ok else &amp;#39;❌&amp;#39;}&amp;quot;)
        
        # Generate report
        report = self.generate_report()
        
        if report[&amp;#39;passed&amp;#39;]:
            print(&amp;quot;🎉 Security audit passed!&amp;quot;)
        else:
            print(f&amp;quot;💥 Security audit failed with {report[&amp;#39;total_issues&amp;#39;]} issues&amp;quot;)
            for issue in self.issues:
                print(f&amp;quot;  - {issue[&amp;#39;severity&amp;#39;].upper()}: {issue[&amp;#39;description&amp;#39;]}&amp;quot;)
        
        return report[&amp;#39;passed&amp;#39;]

if __name__ == &amp;quot;__main__&amp;quot;:
    import argparse
    
    parser = argparse.ArgumentParser(description=&amp;#39;Run security audit&amp;#39;)
    parser.add_argument(&amp;#39;--image&amp;#39;, help=&amp;#39;Docker image to audit&amp;#39;)
    parser.add_argument(&amp;#39;--config&amp;#39;, help=&amp;#39;Kubernetes config path to audit&amp;#39;)
    args = parser.parse_args()
    
    audit = SecurityAudit()
    success = audit.run_full_audit(args.image, args.config)
    
    sys.exit(0 if success else 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;That is the end of the series: setup and first spiders in Part 1, JavaScript rendering and forms in Part 2, anti-detection and distributed scaling in Part 3, data pipelines and storage in Part 4, and deployment here. When something breaks in production, and it will, the official &lt;a href=&quot;https://docs.scrapy.org&quot;&gt;Scrapy documentation&lt;/a&gt; is still the reference I reach for first.&lt;/p&gt;
</content:encoded></item><item><title>Scrapy, part 4: cleaning and storing the data</title><link>https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-4/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-4/</guid><description>Scraped data is messy; delivered data can&apos;t be. Validation pipelines and storage across MongoDB, PostgreSQL, and Elasticsearch.</description><pubDate>Tue, 24 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Scrapy Part 4: Data Processing and Storage&lt;/h1&gt;
&lt;p&gt;Scraped data arrives messy: inconsistent price formats, broken URLs, duplicates, missing fields. This part builds the pipeline layer that fixes that. You will write a validation and cleaning pipeline, an enrichment pipeline for computed fields, and storage backends for MongoDB and PostgreSQL with deduplication, batch upserts, and analytics queries.&lt;/p&gt;
&lt;h2&gt;Data Cleaning and Validation&lt;/h2&gt;
&lt;h3&gt;Validation Pipeline&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/pipelines.py
import re
import hashlib
import json
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem
import logging

class AdvancedValidationPipeline:
    &amp;quot;&amp;quot;&amp;quot;Comprehensive data validation and cleaning pipeline&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.logger = logging.getLogger(__name__)
        self.validation_stats = {
            &amp;#39;total_items&amp;#39;: 0,
            &amp;#39;valid_items&amp;#39;: 0,
            &amp;#39;dropped_items&amp;#39;: 0,
            &amp;#39;field_fixes&amp;#39;: 0,
            &amp;#39;validation_errors&amp;#39;: {}
        }
        
        # Validation rules configuration
        self.validation_rules = {
            &amp;#39;required_fields&amp;#39;: [&amp;#39;name&amp;#39;, &amp;#39;url&amp;#39;],
            &amp;#39;field_types&amp;#39;: {
                &amp;#39;price&amp;#39;: (int, float),
                &amp;#39;rating&amp;#39;: (int, float),
                &amp;#39;review_count&amp;#39;: int,
                &amp;#39;in_stock&amp;#39;: bool
            },
            &amp;#39;field_ranges&amp;#39;: {
                &amp;#39;price&amp;#39;: (0, 1000000),
                &amp;#39;rating&amp;#39;: (0, 5),
                &amp;#39;review_count&amp;#39;: (0, float(&amp;#39;inf&amp;#39;))
            },
            &amp;#39;string_patterns&amp;#39;: {
                &amp;#39;email&amp;#39;: r&amp;#39;^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$&amp;#39;,
                &amp;#39;url&amp;#39;: r&amp;#39;^https?:\/\/[^\s/$.?#].[^\s]*$&amp;#39;,
                &amp;#39;phone&amp;#39;: r&amp;#39;^[\+]?[1-9][\d]{0,15}$&amp;#39;
            },
            &amp;#39;max_lengths&amp;#39;: {
                &amp;#39;name&amp;#39;: 200,
                &amp;#39;description&amp;#39;: 5000,
                &amp;#39;brand&amp;#39;: 100
            }
        }
    
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        self.validation_stats[&amp;#39;total_items&amp;#39;] += 1
        
        try:
            # Clean and validate all fields
            self._clean_item_fields(adapter)
            self._validate_required_fields(adapter)
            self._validate_field_types(adapter)
            self._validate_field_ranges(adapter)
            self._validate_string_patterns(adapter)
            self._validate_field_lengths(adapter)
            
            # Additional business logic validation
            self._validate_business_rules(adapter)
            
            # Generate quality score
            adapter[&amp;#39;data_quality_score&amp;#39;] = self._calculate_quality_score(adapter)
            
            # Add validation metadata
            adapter[&amp;#39;validation_timestamp&amp;#39;] = datetime.now().isoformat()
            adapter[&amp;#39;validation_passed&amp;#39;] = True
            
            self.validation_stats[&amp;#39;valid_items&amp;#39;] += 1
            return item
            
        except ValidationError as e:
            self.validation_stats[&amp;#39;dropped_items&amp;#39;] += 1
            error_type = type(e).__name__
            self.validation_stats[&amp;#39;validation_errors&amp;#39;][error_type] = \
                self.validation_stats[&amp;#39;validation_errors&amp;#39;].get(error_type, 0) + 1
            
            self.logger.warning(f&amp;quot;Validation failed for item: {e}&amp;quot;)
            raise DropItem(f&amp;quot;Validation error: {e}&amp;quot;)
    
    def _clean_item_fields(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Clean and normalize field values&amp;quot;&amp;quot;&amp;quot;
        # Clean text fields
        text_fields = [&amp;#39;name&amp;#39;, &amp;#39;description&amp;#39;, &amp;#39;brand&amp;#39;, &amp;#39;category&amp;#39;]
        for field in text_fields:
            if adapter.get(field):
                cleaned = self._clean_text(adapter[field])
                if cleaned != adapter[field]:
                    adapter[field] = cleaned
                    self.validation_stats[&amp;#39;field_fixes&amp;#39;] += 1
        
        # Clean price field
        if adapter.get(&amp;#39;price&amp;#39;):
            cleaned_price = self._clean_price(adapter[&amp;#39;price&amp;#39;])
            if cleaned_price != adapter[&amp;#39;price&amp;#39;]:
                adapter[&amp;#39;price&amp;#39;] = cleaned_price
                self.validation_stats[&amp;#39;field_fixes&amp;#39;] += 1
        
        # Clean URL fields
        url_fields = [&amp;#39;url&amp;#39;, &amp;#39;image_url&amp;#39;]
        for field in url_fields:
            if adapter.get(field):
                cleaned_url = self._clean_url(adapter[field])
                if cleaned_url != adapter[field]:
                    adapter[field] = cleaned_url
                    self.validation_stats[&amp;#39;field_fixes&amp;#39;] += 1
        
        # Normalize boolean fields
        bool_fields = [&amp;#39;in_stock&amp;#39;, &amp;#39;featured&amp;#39;, &amp;#39;on_sale&amp;#39;]
        for field in bool_fields:
            if field in adapter:
                adapter[field] = self._normalize_boolean(adapter[field])
    
    def _clean_text(self, text: str) -&amp;gt; str:
        &amp;quot;&amp;quot;&amp;quot;Clean and normalize text content&amp;quot;&amp;quot;&amp;quot;
        if not isinstance(text, str):
            text = str(text)
        
        # Remove excessive whitespace
        text = re.sub(r&amp;#39;\s+&amp;#39;, &amp;#39; &amp;#39;, text.strip())
        
        # Remove HTML tags
        text = re.sub(r&amp;#39;&amp;lt;[^&amp;gt;]+&amp;gt;&amp;#39;, &amp;#39;&amp;#39;, text)
        
        # Remove special characters that might cause issues
        text = re.sub(r&amp;#39;[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff]&amp;#39;, &amp;#39;&amp;#39;, text)
        
        # Normalize quotes
        text = text.replace(&amp;#39;&amp;quot;&amp;#39;, &amp;#39;&amp;quot;&amp;#39;).replace(&amp;#39;&amp;quot;&amp;#39;, &amp;#39;&amp;quot;&amp;#39;)
        text = text.replace(&amp;#39;&amp;#39;&amp;#39;, &amp;quot;&amp;#39;&amp;quot;).replace(&amp;#39;&amp;#39;&amp;#39;, &amp;quot;&amp;#39;&amp;quot;)
        
        return text
    
    def _clean_price(self, price: Any) -&amp;gt; Optional[float]:
        &amp;quot;&amp;quot;&amp;quot;Clean and convert price to float&amp;quot;&amp;quot;&amp;quot;
        if price is None:
            return None
        
        if isinstance(price, (int, float)):
            return float(price)
        
        if isinstance(price, str):
            # Remove currency symbols and whitespace
            price_clean = re.sub(r&amp;#39;[^\d.,]&amp;#39;, &amp;#39;&amp;#39;, price)
            
            # Handle different decimal separators
            if &amp;#39;,&amp;#39; in price_clean and &amp;#39;.&amp;#39; in price_clean:
                # Assume comma is thousands separator
                price_clean = price_clean.replace(&amp;#39;,&amp;#39;, &amp;#39;&amp;#39;)
            elif &amp;#39;,&amp;#39; in price_clean:
                # Could be decimal separator or thousands
                if price_clean.count(&amp;#39;,&amp;#39;) == 1 and len(price_clean.split(&amp;#39;,&amp;#39;)[1]) &amp;lt;= 2:
                    price_clean = price_clean.replace(&amp;#39;,&amp;#39;, &amp;#39;.&amp;#39;)
                else:
                    price_clean = price_clean.replace(&amp;#39;,&amp;#39;, &amp;#39;&amp;#39;)
            
            try:
                return float(price_clean)
            except ValueError:
                return None
        
        return None
    
    def _clean_url(self, url: str) -&amp;gt; str:
        &amp;quot;&amp;quot;&amp;quot;Clean and normalize URLs&amp;quot;&amp;quot;&amp;quot;
        if not isinstance(url, str):
            return str(url)
        
        url = url.strip()
        
        # Add protocol if missing
        if url.startswith(&amp;#39;//&amp;#39;):
            url = &amp;#39;https:&amp;#39; + url
        elif not url.startswith((&amp;#39;http://&amp;#39;, &amp;#39;https://&amp;#39;)):
            url = &amp;#39;https://&amp;#39; + url
        
        return url
    
    def _normalize_boolean(self, value: Any) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Normalize various boolean representations&amp;quot;&amp;quot;&amp;quot;
        if isinstance(value, bool):
            return value
        
        if isinstance(value, str):
            value = value.lower().strip()
            return value in (&amp;#39;true&amp;#39;, &amp;#39;yes&amp;#39;, &amp;#39;1&amp;#39;, &amp;#39;on&amp;#39;, &amp;#39;available&amp;#39;, &amp;#39;in stock&amp;#39;)
        
        if isinstance(value, (int, float)):
            return bool(value)
        
        return False
    
    def _validate_required_fields(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate that required fields are present&amp;quot;&amp;quot;&amp;quot;
        for field in self.validation_rules[&amp;#39;required_fields&amp;#39;]:
            if not adapter.get(field):
                raise ValidationError(f&amp;quot;Required field missing: {field}&amp;quot;)
    
    def _validate_field_types(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate field data types&amp;quot;&amp;quot;&amp;quot;
        for field, expected_types in self.validation_rules[&amp;#39;field_types&amp;#39;].items():
            if field in adapter and adapter[field] is not None:
                if not isinstance(adapter[field], expected_types):
                    raise ValidationError(
                        f&amp;quot;Field {field} has invalid type: &amp;quot;
                        f&amp;quot;expected {expected_types}, got {type(adapter[field])}&amp;quot;
                    )
    
    def _validate_field_ranges(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate numeric field ranges&amp;quot;&amp;quot;&amp;quot;
        for field, (min_val, max_val) in self.validation_rules[&amp;#39;field_ranges&amp;#39;].items():
            if field in adapter and adapter[field] is not None:
                value = adapter[field]
                if not (min_val &amp;lt;= value &amp;lt;= max_val):
                    raise ValidationError(
                        f&amp;quot;Field {field} value {value} outside valid range [{min_val}, {max_val}]&amp;quot;
                    )
    
    def _validate_string_patterns(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate string fields against regex patterns&amp;quot;&amp;quot;&amp;quot;
        for field, pattern in self.validation_rules[&amp;#39;string_patterns&amp;#39;].items():
            if field in adapter and adapter[field] is not None:
                if not re.match(pattern, str(adapter[field])):
                    raise ValidationError(f&amp;quot;Field {field} doesn&amp;#39;t match required pattern&amp;quot;)
    
    def _validate_field_lengths(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate string field lengths&amp;quot;&amp;quot;&amp;quot;
        for field, max_length in self.validation_rules[&amp;#39;max_lengths&amp;#39;].items():
            if field in adapter and adapter[field] is not None:
                if len(str(adapter[field])) &amp;gt; max_length:
                    raise ValidationError(
                        f&amp;quot;Field {field} exceeds maximum length of {max_length}&amp;quot;
                    )
    
    def _validate_business_rules(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate business-specific rules&amp;quot;&amp;quot;&amp;quot;
        # Example business rules
        
        # Price must be positive for in-stock items
        if adapter.get(&amp;#39;in_stock&amp;#39;) and adapter.get(&amp;#39;price&amp;#39;, 0) &amp;lt;= 0:
            raise ValidationError(&amp;quot;In-stock items must have positive price&amp;quot;)
        
        # Rating must be present if review_count &amp;gt; 0
        if adapter.get(&amp;#39;review_count&amp;#39;, 0) &amp;gt; 0 and not adapter.get(&amp;#39;rating&amp;#39;):
            raise ValidationError(&amp;quot;Items with reviews must have rating&amp;quot;)
        
        # Discount validation
        original_price = adapter.get(&amp;#39;original_price&amp;#39;)
        current_price = adapter.get(&amp;#39;price&amp;#39;)
        if original_price and current_price:
            if current_price &amp;gt; original_price:
                raise ValidationError(&amp;quot;Current price cannot exceed original price&amp;quot;)
    
    def _calculate_quality_score(self, adapter) -&amp;gt; float:
        &amp;quot;&amp;quot;&amp;quot;Calculate data quality score (0-1)&amp;quot;&amp;quot;&amp;quot;
        score = 0.0
        max_score = 0.0
        
        # Completeness score (40% of total)
        important_fields = [&amp;#39;name&amp;#39;, &amp;#39;price&amp;#39;, &amp;#39;description&amp;#39;, &amp;#39;brand&amp;#39;, &amp;#39;category&amp;#39;]
        present_fields = sum(1 for field in important_fields if adapter.get(field))
        completeness = present_fields / len(important_fields)
        score += completeness * 0.4
        max_score += 0.4
        
        # Accuracy score (30% of total)
        accuracy = 1.0  # Assume high accuracy if validation passed
        score += accuracy * 0.3
        max_score += 0.3
        
        # Consistency score (20% of total)
        consistency = self._check_consistency(adapter)
        score += consistency * 0.2
        max_score += 0.2
        
        # Freshness score (10% of total)
        freshness = 1.0  # Assume fresh data
        score += freshness * 0.1
        max_score += 0.1
        
        return score / max_score if max_score &amp;gt; 0 else 0.0
    
    def _check_consistency(self, adapter) -&amp;gt; float:
        &amp;quot;&amp;quot;&amp;quot;Check internal consistency of data&amp;quot;&amp;quot;&amp;quot;
        consistency_score = 1.0
        
        # Check if price and in_stock are consistent
        if adapter.get(&amp;#39;price&amp;#39;, 0) &amp;lt;= 0 and adapter.get(&amp;#39;in_stock&amp;#39;, False):
            consistency_score -= 0.3
        
        # Check if rating and review_count are consistent
        rating = adapter.get(&amp;#39;rating&amp;#39;)
        review_count = adapter.get(&amp;#39;review_count&amp;#39;, 0)
        if rating and rating &amp;gt; 0 and review_count == 0:
            consistency_score -= 0.2
        
        return max(0.0, consistency_score)
    
    def close_spider(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Log validation statistics when spider closes&amp;quot;&amp;quot;&amp;quot;
        stats = self.validation_stats
        total = stats[&amp;#39;total_items&amp;#39;]
        
        self.logger.info(&amp;quot;=== Validation Pipeline Statistics ===&amp;quot;)
        self.logger.info(f&amp;quot;Total items processed: {total}&amp;quot;)
        self.logger.info(f&amp;quot;Valid items: {stats[&amp;#39;valid_items&amp;#39;]} ({stats[&amp;#39;valid_items&amp;#39;]/total*100:.1f}%)&amp;quot;)
        self.logger.info(f&amp;quot;Dropped items: {stats[&amp;#39;dropped_items&amp;#39;]} ({stats[&amp;#39;dropped_items&amp;#39;]/total*100:.1f}%)&amp;quot;)
        self.logger.info(f&amp;quot;Field fixes applied: {stats[&amp;#39;field_fixes&amp;#39;]}&amp;quot;)
        
        if stats[&amp;#39;validation_errors&amp;#39;]:
            self.logger.info(&amp;quot;Validation errors by type:&amp;quot;)
            for error_type, count in stats[&amp;#39;validation_errors&amp;#39;].items():
                self.logger.info(f&amp;quot;  {error_type}: {count}&amp;quot;)

class ValidationError(Exception):
    &amp;quot;&amp;quot;&amp;quot;Custom exception for validation errors&amp;quot;&amp;quot;&amp;quot;
    pass

class DataEnrichmentPipeline:
    &amp;quot;&amp;quot;&amp;quot;Enrich items with additional computed fields&amp;quot;&amp;quot;&amp;quot;
    
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        
        # Add computed fields
        self._add_price_analysis(adapter)
        self._add_text_analysis(adapter)
        self._add_category_classification(adapter)
        self._add_uniqueness_hash(adapter)
        
        return item
    
    def _add_price_analysis(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Add price-related computed fields&amp;quot;&amp;quot;&amp;quot;
        current_price = adapter.get(&amp;#39;price&amp;#39;)
        original_price = adapter.get(&amp;#39;original_price&amp;#39;)
        
        if current_price and original_price:
            discount = original_price - current_price
            discount_percent = (discount / original_price) * 100
            
            adapter[&amp;#39;discount_amount&amp;#39;] = discount
            adapter[&amp;#39;discount_percentage&amp;#39;] = round(discount_percent, 2)
            adapter[&amp;#39;is_on_sale&amp;#39;] = discount &amp;gt; 0
        
        # Price tier classification
        if current_price:
            if current_price &amp;lt; 50:
                adapter[&amp;#39;price_tier&amp;#39;] = &amp;#39;budget&amp;#39;
            elif current_price &amp;lt; 200:
                adapter[&amp;#39;price_tier&amp;#39;] = &amp;#39;mid-range&amp;#39;
            elif current_price &amp;lt; 500:
                adapter[&amp;#39;price_tier&amp;#39;] = &amp;#39;premium&amp;#39;
            else:
                adapter[&amp;#39;price_tier&amp;#39;] = &amp;#39;luxury&amp;#39;
    
    def _add_text_analysis(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Add text analysis metrics&amp;quot;&amp;quot;&amp;quot;
        description = adapter.get(&amp;#39;description&amp;#39;, &amp;#39;&amp;#39;)
        
        if description:
            words = description.split()
            adapter[&amp;#39;description_word_count&amp;#39;] = len(words)
            adapter[&amp;#39;description_char_count&amp;#39;] = len(description)
            
            # Simple sentiment analysis (in production, use proper NLP)
            positive_words = [&amp;#39;excellent&amp;#39;, &amp;#39;great&amp;#39;, &amp;#39;amazing&amp;#39;, &amp;#39;perfect&amp;#39;, &amp;#39;wonderful&amp;#39;]
            negative_words = [&amp;#39;bad&amp;#39;, &amp;#39;terrible&amp;#39;, &amp;#39;awful&amp;#39;, &amp;#39;poor&amp;#39;, &amp;#39;disappointing&amp;#39;]
            
            pos_count = sum(1 for word in words if word.lower() in positive_words)
            neg_count = sum(1 for word in words if word.lower() in negative_words)
            
            if pos_count &amp;gt; neg_count:
                adapter[&amp;#39;sentiment&amp;#39;] = &amp;#39;positive&amp;#39;
            elif neg_count &amp;gt; pos_count:
                adapter[&amp;#39;sentiment&amp;#39;] = &amp;#39;negative&amp;#39;
            else:
                adapter[&amp;#39;sentiment&amp;#39;] = &amp;#39;neutral&amp;#39;
    
    def _add_category_classification(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Add automatic category classification&amp;quot;&amp;quot;&amp;quot;
        name = adapter.get(&amp;#39;name&amp;#39;, &amp;#39;&amp;#39;).lower()
        description = adapter.get(&amp;#39;description&amp;#39;, &amp;#39;&amp;#39;).lower()
        text = f&amp;quot;{name} {description}&amp;quot;
        
        # Simple keyword-based classification
        categories = {
            &amp;#39;electronics&amp;#39;: [&amp;#39;phone&amp;#39;, &amp;#39;laptop&amp;#39;, &amp;#39;computer&amp;#39;, &amp;#39;tablet&amp;#39;, &amp;#39;tv&amp;#39;, &amp;#39;camera&amp;#39;],
            &amp;#39;clothing&amp;#39;: [&amp;#39;shirt&amp;#39;, &amp;#39;pants&amp;#39;, &amp;#39;dress&amp;#39;, &amp;#39;shoes&amp;#39;, &amp;#39;jacket&amp;#39;, &amp;#39;jeans&amp;#39;],
            &amp;#39;home&amp;#39;: [&amp;#39;furniture&amp;#39;, &amp;#39;kitchen&amp;#39;, &amp;#39;bedroom&amp;#39;, &amp;#39;bathroom&amp;#39;, &amp;#39;decor&amp;#39;],
            &amp;#39;books&amp;#39;: [&amp;#39;book&amp;#39;, &amp;#39;novel&amp;#39;, &amp;#39;textbook&amp;#39;, &amp;#39;manual&amp;#39;, &amp;#39;guide&amp;#39;],
            &amp;#39;sports&amp;#39;: [&amp;#39;fitness&amp;#39;, &amp;#39;exercise&amp;#39;, &amp;#39;sports&amp;#39;, &amp;#39;outdoor&amp;#39;, &amp;#39;gym&amp;#39;]
        }
        
        scores = {}
        for category, keywords in categories.items():
            score = sum(1 for keyword in keywords if keyword in text)
            if score &amp;gt; 0:
                scores[category] = score
        
        if scores:
            adapter[&amp;#39;auto_category&amp;#39;] = max(scores, key=scores.get)
            adapter[&amp;#39;category_confidence&amp;#39;] = max(scores.values()) / len(categories[adapter[&amp;#39;auto_category&amp;#39;]])
    
    def _add_uniqueness_hash(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Add uniqueness hash for deduplication&amp;quot;&amp;quot;&amp;quot;
        # Create hash from key identifying fields
        key_fields = [&amp;#39;name&amp;#39;, &amp;#39;brand&amp;#39;, &amp;#39;sku&amp;#39;]
        hash_input = &amp;#39;&amp;#39;.join(str(adapter.get(field, &amp;#39;&amp;#39;)) for field in key_fields)
        
        if hash_input.strip():
            adapter[&amp;#39;uniqueness_hash&amp;#39;] = hashlib.md5(
                hash_input.lower().encode(&amp;#39;utf-8&amp;#39;)
            ).hexdigest()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Multiple Storage Backends&lt;/h2&gt;
&lt;h3&gt;MongoDB Storage Pipeline&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/storage/mongodb_pipeline.py
import pymongo
from pymongo import MongoClient, UpdateOne
from itemadapter import ItemAdapter
from datetime import datetime
import logging
from typing import Dict, List

class MongoDBPipeline:
    &amp;quot;&amp;quot;&amp;quot;Advanced MongoDB storage pipeline with indexing and aggregation&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, mongo_uri, mongo_db, collection_name):
        self.mongo_uri = mongo_uri
        self.mongo_db = mongo_db
        self.collection_name = collection_name
        self.client = None
        self.db = None
        self.collection = None
        self.logger = logging.getLogger(__name__)
        
        # Batch processing
        self.batch_size = 100
        self.batch_items = []
        
        # Statistics
        self.stats = {
            &amp;#39;inserted&amp;#39;: 0,
            &amp;#39;updated&amp;#39;: 0,
            &amp;#39;duplicates&amp;#39;: 0,
            &amp;#39;errors&amp;#39;: 0
        }
    
    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            mongo_uri=crawler.settings.get(&amp;quot;MONGO_URI&amp;quot;, &amp;quot;mongodb://localhost:27017&amp;quot;),
            mongo_db=crawler.settings.get(&amp;quot;MONGO_DATABASE&amp;quot;, &amp;quot;scrapy&amp;quot;),
            collection_name=crawler.settings.get(&amp;quot;MONGO_COLLECTION&amp;quot;, &amp;quot;items&amp;quot;)
        )
    
    def open_spider(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Initialize MongoDB connection and setup indexes&amp;quot;&amp;quot;&amp;quot;
        self.client = MongoClient(self.mongo_uri)
        self.db = self.client[self.mongo_db]
        self.collection = self.db[self.collection_name]
        
        # Create indexes for performance
        self._create_indexes()
        
        self.logger.info(f&amp;quot;Connected to MongoDB: {self.mongo_uri}/{self.mongo_db}&amp;quot;)
    
    def _create_indexes(self):
        &amp;quot;&amp;quot;&amp;quot;Create database indexes for optimal performance&amp;quot;&amp;quot;&amp;quot;
        indexes = [
            # Unique index for deduplication
            (&amp;quot;uniqueness_hash&amp;quot;, pymongo.ASCENDING),
            
            # Search indexes
            (&amp;quot;name&amp;quot;, pymongo.TEXT),
            (&amp;quot;description&amp;quot;, pymongo.TEXT),
            (&amp;quot;brand&amp;quot;, pymongo.ASCENDING),
            (&amp;quot;category&amp;quot;, pymongo.ASCENDING),
            
            # Filter indexes
            (&amp;quot;price&amp;quot;, pymongo.ASCENDING),
            (&amp;quot;rating&amp;quot;, pymongo.DESCENDING),
            (&amp;quot;in_stock&amp;quot;, pymongo.ASCENDING),
            
            # Time-based indexes
            (&amp;quot;scraped_at&amp;quot;, pymongo.DESCENDING),
            (&amp;quot;updated_at&amp;quot;, pymongo.DESCENDING),
            
            # Compound indexes
            ((&amp;quot;category&amp;quot;, pymongo.ASCENDING), (&amp;quot;price&amp;quot;, pymongo.ASCENDING)),
            ((&amp;quot;brand&amp;quot;, pymongo.ASCENDING), (&amp;quot;rating&amp;quot;, pymongo.DESCENDING)),
        ]
        
        for index in indexes:
            try:
                if isinstance(index, tuple):
                    self.collection.create_index([index])
                else:
                    self.collection.create_index(index)
            except Exception as e:
                self.logger.warning(f&amp;quot;Failed to create index {index}: {e}&amp;quot;)
        
        # Create text index for search
        try:
            self.collection.create_index([
                (&amp;quot;name&amp;quot;, pymongo.TEXT),
                (&amp;quot;description&amp;quot;, pymongo.TEXT),
                (&amp;quot;brand&amp;quot;, pymongo.TEXT)
            ], name=&amp;quot;text_search&amp;quot;)
        except Exception as e:
            self.logger.warning(f&amp;quot;Failed to create text index: {e}&amp;quot;)
    
    def process_item(self, item, spider):
        &amp;quot;&amp;quot;&amp;quot;Process item and add to batch&amp;quot;&amp;quot;&amp;quot;
        adapter = ItemAdapter(item)
        
        # Add MongoDB-specific fields
        adapter[&amp;#39;_scraped_at&amp;#39;] = datetime.utcnow()
        adapter[&amp;#39;_spider_name&amp;#39;] = spider.name
        
        # Add to batch
        self.batch_items.append(dict(adapter))
        
        # Process batch if full
        if len(self.batch_items) &amp;gt;= self.batch_size:
            self._process_batch()
        
        return item
    
    def _process_batch(self):
        &amp;quot;&amp;quot;&amp;quot;Process batch of items with upsert operations&amp;quot;&amp;quot;&amp;quot;
        if not self.batch_items:
            return
        
        try:
            operations = []
            
            for item in self.batch_items:
                uniqueness_hash = item.get(&amp;#39;uniqueness_hash&amp;#39;)
                
                if uniqueness_hash:
                    # Upsert based on uniqueness hash
                    operation = UpdateOne(
                        {&amp;#39;uniqueness_hash&amp;#39;: uniqueness_hash},
                        {
                            &amp;#39;$set&amp;#39;: item,
                            &amp;#39;$setOnInsert&amp;#39;: {&amp;#39;_created_at&amp;#39;: datetime.utcnow()},
                            &amp;#39;$currentDate&amp;#39;: {&amp;#39;_updated_at&amp;#39;: True}
                        },
                        upsert=True
                    )
                    operations.append(operation)
                else:
                    # Insert without duplicate check
                    self.collection.insert_one(item)
                    self.stats[&amp;#39;inserted&amp;#39;] += 1
            
            if operations:
                result = self.collection.bulk_write(operations, ordered=False)
                self.stats[&amp;#39;inserted&amp;#39;] += result.upserted_count
                self.stats[&amp;#39;updated&amp;#39;] += result.modified_count
                self.stats[&amp;#39;duplicates&amp;#39;] += len(operations) - result.upserted_count - result.modified_count
            
        except Exception as e:
            self.stats[&amp;#39;errors&amp;#39;] += len(self.batch_items)
            self.logger.error(f&amp;quot;Error processing batch: {e}&amp;quot;)
        
        finally:
            self.batch_items.clear()
    
    def close_spider(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Process remaining items and close connection&amp;quot;&amp;quot;&amp;quot;
        # Process remaining batch
        self._process_batch()
        
        # Log statistics
        self.logger.info(&amp;quot;=== MongoDB Pipeline Statistics ===&amp;quot;)
        for stat, count in self.stats.items():
            self.logger.info(f&amp;quot;{stat.capitalize()}: {count}&amp;quot;)
        
        # Create aggregation pipelines for analytics
        self._create_analytics_views()
        
        # Close connection
        if self.client:
            self.client.close()
    
    def _create_analytics_views(self):
        &amp;quot;&amp;quot;&amp;quot;Create MongoDB views for analytics&amp;quot;&amp;quot;&amp;quot;
        try:
            # Category statistics view
            self.db.create_collection(&amp;quot;category_stats&amp;quot;, viewOn=self.collection_name, pipeline=[
                {&amp;quot;$group&amp;quot;: {
                    &amp;quot;_id&amp;quot;: &amp;quot;$category&amp;quot;,
                    &amp;quot;total_products&amp;quot;: {&amp;quot;$sum&amp;quot;: 1},
                    &amp;quot;avg_price&amp;quot;: {&amp;quot;$avg&amp;quot;: &amp;quot;$price&amp;quot;},
                    &amp;quot;avg_rating&amp;quot;: {&amp;quot;$avg&amp;quot;: &amp;quot;$rating&amp;quot;},
                    &amp;quot;in_stock_count&amp;quot;: {&amp;quot;$sum&amp;quot;: {&amp;quot;$cond&amp;quot;: [&amp;quot;$in_stock&amp;quot;, 1, 0]}}
                }},
                {&amp;quot;$sort&amp;quot;: {&amp;quot;total_products&amp;quot;: -1}}
            ])
            
            # Brand statistics view
            self.db.create_collection(&amp;quot;brand_stats&amp;quot;, viewOn=self.collection_name, pipeline=[
                {&amp;quot;$group&amp;quot;: {
                    &amp;quot;_id&amp;quot;: &amp;quot;$brand&amp;quot;,
                    &amp;quot;total_products&amp;quot;: {&amp;quot;$sum&amp;quot;: 1},
                    &amp;quot;avg_price&amp;quot;: {&amp;quot;$avg&amp;quot;: &amp;quot;$price&amp;quot;},
                    &amp;quot;price_range&amp;quot;: {
                        &amp;quot;min&amp;quot;: {&amp;quot;$min&amp;quot;: &amp;quot;$price&amp;quot;},
                        &amp;quot;max&amp;quot;: {&amp;quot;$max&amp;quot;: &amp;quot;$price&amp;quot;}
                    }
                }},
                {&amp;quot;$sort&amp;quot;: {&amp;quot;total_products&amp;quot;: -1}}
            ])
            
            # Daily scraping statistics
            self.db.create_collection(&amp;quot;daily_stats&amp;quot;, viewOn=self.collection_name, pipeline=[
                {&amp;quot;$group&amp;quot;: {
                    &amp;quot;_id&amp;quot;: {
                        &amp;quot;$dateToString&amp;quot;: {
                            &amp;quot;format&amp;quot;: &amp;quot;%Y-%m-%d&amp;quot;,
                            &amp;quot;date&amp;quot;: &amp;quot;$_scraped_at&amp;quot;
                        }
                    },
                    &amp;quot;items_scraped&amp;quot;: {&amp;quot;$sum&amp;quot;: 1},
                    &amp;quot;unique_brands&amp;quot;: {&amp;quot;$addToSet&amp;quot;: &amp;quot;$brand&amp;quot;},
                    &amp;quot;unique_categories&amp;quot;: {&amp;quot;$addToSet&amp;quot;: &amp;quot;$category&amp;quot;}
                }},
                {&amp;quot;$addFields&amp;quot;: {
                    &amp;quot;unique_brand_count&amp;quot;: {&amp;quot;$size&amp;quot;: &amp;quot;$unique_brands&amp;quot;},
                    &amp;quot;unique_category_count&amp;quot;: {&amp;quot;$size&amp;quot;: &amp;quot;$unique_categories&amp;quot;}
                }},
                {&amp;quot;$sort&amp;quot;: {&amp;quot;_id&amp;quot;: -1}}
            ])
            
        except Exception as e:
            self.logger.warning(f&amp;quot;Failed to create analytics views: {e}&amp;quot;)

class MongoDBAnalytics:
    &amp;quot;&amp;quot;&amp;quot;Advanced analytics queries for MongoDB&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, mongo_uri, mongo_db, collection_name):
        self.client = MongoClient(mongo_uri)
        self.db = self.client[mongo_db]
        self.collection = self.db[collection_name]
    
    def get_price_trends(self, days: int = 30) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Get price trends over time&amp;quot;&amp;quot;&amp;quot;
        pipeline = [
            {
                &amp;quot;$match&amp;quot;: {
                    &amp;quot;_scraped_at&amp;quot;: {
                        &amp;quot;$gte&amp;quot;: datetime.utcnow() - timedelta(days=days)
                    }
                }
            },
            {
                &amp;quot;$group&amp;quot;: {
                    &amp;quot;_id&amp;quot;: {
                        &amp;quot;date&amp;quot;: {&amp;quot;$dateToString&amp;quot;: {&amp;quot;format&amp;quot;: &amp;quot;%Y-%m-%d&amp;quot;, &amp;quot;date&amp;quot;: &amp;quot;$_scraped_at&amp;quot;}},
                        &amp;quot;category&amp;quot;: &amp;quot;$category&amp;quot;
                    },
                    &amp;quot;avg_price&amp;quot;: {&amp;quot;$avg&amp;quot;: &amp;quot;$price&amp;quot;},
                    &amp;quot;min_price&amp;quot;: {&amp;quot;$min&amp;quot;: &amp;quot;$price&amp;quot;},
                    &amp;quot;max_price&amp;quot;: {&amp;quot;$max&amp;quot;: &amp;quot;$price&amp;quot;},
                    &amp;quot;product_count&amp;quot;: {&amp;quot;$sum&amp;quot;: 1}
                }
            },
            {&amp;quot;$sort&amp;quot;: {&amp;quot;_id.date&amp;quot;: 1}}
        ]
        
        return list(self.collection.aggregate(pipeline))
    
    def get_top_products(self, category: str = None, limit: int = 10) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Get top-rated products&amp;quot;&amp;quot;&amp;quot;
        match_stage = {&amp;quot;rating&amp;quot;: {&amp;quot;$exists&amp;quot;: True, &amp;quot;$gte&amp;quot;: 4.0}}
        if category:
            match_stage[&amp;quot;category&amp;quot;] = category
        
        pipeline = [
            {&amp;quot;$match&amp;quot;: match_stage},
            {&amp;quot;$sort&amp;quot;: {&amp;quot;rating&amp;quot;: -1, &amp;quot;review_count&amp;quot;: -1}},
            {&amp;quot;$limit&amp;quot;: limit},
            {
                &amp;quot;$project&amp;quot;: {
                    &amp;quot;name&amp;quot;: 1,
                    &amp;quot;brand&amp;quot;: 1,
                    &amp;quot;price&amp;quot;: 1,
                    &amp;quot;rating&amp;quot;: 1,
                    &amp;quot;review_count&amp;quot;: 1,
                    &amp;quot;url&amp;quot;: 1
                }
            }
        ]
        
        return list(self.collection.aggregate(pipeline))
    
    def get_inventory_alerts(self) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Get products that went out of stock recently&amp;quot;&amp;quot;&amp;quot;
        pipeline = [
            {
                &amp;quot;$match&amp;quot;: {
                    &amp;quot;in_stock&amp;quot;: False,
                    &amp;quot;_scraped_at&amp;quot;: {&amp;quot;$gte&amp;quot;: datetime.utcnow() - timedelta(days=1)}
                }
            },
            {
                &amp;quot;$project&amp;quot;: {
                    &amp;quot;name&amp;quot;: 1,
                    &amp;quot;brand&amp;quot;: 1,
                    &amp;quot;price&amp;quot;: 1,
                    &amp;quot;category&amp;quot;: 1,
                    &amp;quot;_scraped_at&amp;quot;: 1
                }
            },
            {&amp;quot;$sort&amp;quot;: {&amp;quot;_scraped_at&amp;quot;: -1}}
        ]
        
        return list(self.collection.aggregate(pipeline))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;PostgreSQL Storage Pipeline&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/storage/postgresql_pipeline.py
import psycopg2
import psycopg2.extras
from itemadapter import ItemAdapter
from datetime import datetime
import logging
import json
from typing import Dict, List, Optional

class PostgreSQLPipeline:
    &amp;quot;&amp;quot;&amp;quot;Advanced PostgreSQL storage with JSONB and full-text search&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, postgres_settings):
        self.postgres_settings = postgres_settings
        self.connection = None
        self.cursor = None
        self.logger = logging.getLogger(__name__)
        
        # Batch processing
        self.batch_size = 50
        self.batch_items = []
        
        # Statistics
        self.stats = {
            &amp;#39;inserted&amp;#39;: 0,
            &amp;#39;updated&amp;#39;: 0,
            &amp;#39;errors&amp;#39;: 0
        }
    
    @classmethod
    def from_crawler(cls, crawler):
        postgres_settings = crawler.settings.getdict(&amp;quot;POSTGRES_SETTINGS&amp;quot;)
        return cls(postgres_settings)
    
    def open_spider(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Initialize PostgreSQL connection and setup tables&amp;quot;&amp;quot;&amp;quot;
        try:
            self.connection = psycopg2.connect(**self.postgres_settings)
            self.cursor = self.connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
            
            # Create tables and indexes
            self._create_tables()
            self._create_indexes()
            
            self.logger.info(&amp;quot;Connected to PostgreSQL successfully&amp;quot;)
            
        except Exception as e:
            self.logger.error(f&amp;quot;Error connecting to PostgreSQL: {e}&amp;quot;)
            raise
    
    def _create_tables(self):
        &amp;quot;&amp;quot;&amp;quot;Create database tables&amp;quot;&amp;quot;&amp;quot;
        create_tables_sql = &amp;quot;&amp;quot;&amp;quot;
        -- Main products table
        CREATE TABLE IF NOT EXISTS products (
            id SERIAL PRIMARY KEY,
            uniqueness_hash VARCHAR(32) UNIQUE,
            name VARCHAR(500) NOT NULL,
            brand VARCHAR(100),
            category VARCHAR(100),
            price DECIMAL(10,2),
            original_price DECIMAL(10,2),
            currency VARCHAR(3) DEFAULT &amp;#39;USD&amp;#39;,
            rating DECIMAL(3,2),
            review_count INTEGER,
            in_stock BOOLEAN DEFAULT true,
            description TEXT,
            specifications JSONB,
            images JSONB,
            url VARCHAR(1000),
            source_spider VARCHAR(50),
            data_quality_score DECIMAL(3,2),
            scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            raw_data JSONB
        );
        
        -- Price history table
        CREATE TABLE IF NOT EXISTS price_history (
            id SERIAL PRIMARY KEY,
            product_id INTEGER REFERENCES products(id),
            price DECIMAL(10,2) NOT NULL,
            original_price DECIMAL(10,2),
            scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        );
        
        -- Categories table
        CREATE TABLE IF NOT EXISTS categories (
            id SERIAL PRIMARY KEY,
            name VARCHAR(100) UNIQUE NOT NULL,
            parent_category VARCHAR(100),
            description TEXT
        );
        
        -- Brands table
        CREATE TABLE IF NOT EXISTS brands (
            id SERIAL PRIMARY KEY,
            name VARCHAR(100) UNIQUE NOT NULL,
            website VARCHAR(255),
            description TEXT
        );
        
        -- Search statistics table
        CREATE TABLE IF NOT EXISTS search_stats (
            id SERIAL PRIMARY KEY,
            search_term VARCHAR(255),
            results_count INTEGER,
            avg_price DECIMAL(10,2),
            searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        );
        &amp;quot;&amp;quot;&amp;quot;
        
        try:
            self.cursor.execute(create_tables_sql)
            self.connection.commit()
            self.logger.info(&amp;quot;Database tables created successfully&amp;quot;)
        except Exception as e:
            self.logger.error(f&amp;quot;Error creating tables: {e}&amp;quot;)
            self.connection.rollback()
    
    def _create_indexes(self):
        &amp;quot;&amp;quot;&amp;quot;Create database indexes&amp;quot;&amp;quot;&amp;quot;
        indexes_sql = [
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_name ON products USING gin(to_tsvector(&amp;#39;english&amp;#39;, name));&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_brand ON products(brand);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_price ON products(price);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_rating ON products(rating DESC);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_scraped_at ON products(scraped_at DESC);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_in_stock ON products(in_stock);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_data_quality ON products(data_quality_score DESC);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_price_history_product_id ON price_history(product_id);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_price_history_scraped_at ON price_history(scraped_at DESC);&amp;quot;,
            
            # JSONB indexes
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_specs ON products USING gin(specifications);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_raw_data ON products USING gin(raw_data);&amp;quot;,
            
            # Full-text search index
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_fulltext ON products USING gin(to_tsvector(&amp;#39;english&amp;#39;, name || &amp;#39; &amp;#39; || COALESCE(description, &amp;#39;&amp;#39;)));&amp;quot;
        ]
        
        for index_sql in indexes_sql:
            try:
                self.cursor.execute(index_sql)
                self.connection.commit()
            except Exception as e:
                self.logger.warning(f&amp;quot;Error creating index: {e}&amp;quot;)
                self.connection.rollback()
    
    def process_item(self, item, spider):
        &amp;quot;&amp;quot;&amp;quot;Process item and add to batch&amp;quot;&amp;quot;&amp;quot;
        adapter = ItemAdapter(item)
        
        # Prepare item for PostgreSQL
        postgres_item = self._prepare_item(adapter, spider)
        self.batch_items.append(postgres_item)
        
        # Process batch if full
        if len(self.batch_items) &amp;gt;= self.batch_size:
            self._process_batch()
        
        return item
    
    def _prepare_item(self, adapter: ItemAdapter, spider) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Prepare item for PostgreSQL storage&amp;quot;&amp;quot;&amp;quot;
        return {
            &amp;#39;uniqueness_hash&amp;#39;: adapter.get(&amp;#39;uniqueness_hash&amp;#39;),
            &amp;#39;name&amp;#39;: adapter.get(&amp;#39;name&amp;#39;),
            &amp;#39;brand&amp;#39;: adapter.get(&amp;#39;brand&amp;#39;),
            &amp;#39;category&amp;#39;: adapter.get(&amp;#39;category&amp;#39;),
            &amp;#39;price&amp;#39;: adapter.get(&amp;#39;price&amp;#39;),
            &amp;#39;original_price&amp;#39;: adapter.get(&amp;#39;original_price&amp;#39;),
            &amp;#39;currency&amp;#39;: adapter.get(&amp;#39;currency&amp;#39;, &amp;#39;USD&amp;#39;),
            &amp;#39;rating&amp;#39;: adapter.get(&amp;#39;rating&amp;#39;),
            &amp;#39;review_count&amp;#39;: adapter.get(&amp;#39;review_count&amp;#39;),
            &amp;#39;in_stock&amp;#39;: adapter.get(&amp;#39;in_stock&amp;#39;, True),
            &amp;#39;description&amp;#39;: adapter.get(&amp;#39;description&amp;#39;),
            &amp;#39;specifications&amp;#39;: json.dumps(adapter.get(&amp;#39;specifications&amp;#39;, {})),
            &amp;#39;images&amp;#39;: json.dumps(adapter.get(&amp;#39;images&amp;#39;, [])),
            &amp;#39;url&amp;#39;: adapter.get(&amp;#39;url&amp;#39;),
            &amp;#39;source_spider&amp;#39;: spider.name,
            &amp;#39;data_quality_score&amp;#39;: adapter.get(&amp;#39;data_quality_score&amp;#39;),
            &amp;#39;raw_data&amp;#39;: json.dumps(dict(adapter))
        }
    
    def _process_batch(self):
        &amp;quot;&amp;quot;&amp;quot;Process batch of items with upsert&amp;quot;&amp;quot;&amp;quot;
        if not self.batch_items:
            return
        
        upsert_sql = &amp;quot;&amp;quot;&amp;quot;
        INSERT INTO products (
            uniqueness_hash, name, brand, category, price, original_price,
            currency, rating, review_count, in_stock, description,
            specifications, images, url, source_spider, data_quality_score, raw_data
        ) VALUES (
            %(uniqueness_hash)s, %(name)s, %(brand)s, %(category)s, %(price)s, %(original_price)s,
            %(currency)s, %(rating)s, %(review_count)s, %(in_stock)s, %(description)s,
            %(specifications)s, %(images)s, %(url)s, %(source_spider)s, %(data_quality_score)s, %(raw_data)s
        )
        ON CONFLICT (uniqueness_hash) DO UPDATE SET
            name = EXCLUDED.name,
            brand = EXCLUDED.brand,
            category = EXCLUDED.category,
            price = EXCLUDED.price,
            original_price = EXCLUDED.original_price,
            rating = EXCLUDED.rating,
            review_count = EXCLUDED.review_count,
            in_stock = EXCLUDED.in_stock,
            description = EXCLUDED.description,
            specifications = EXCLUDED.specifications,
            images = EXCLUDED.images,
            updated_at = CURRENT_TIMESTAMP,
            raw_data = EXCLUDED.raw_data
        RETURNING id, (xmax = 0) AS inserted;
        &amp;quot;&amp;quot;&amp;quot;
        
        try:
            # Execute batch upsert
            results = []
            for item in self.batch_items:
                self.cursor.execute(upsert_sql, item)
                result = self.cursor.fetchone()
                results.append(result)
            
            # Insert price history
            self._insert_price_history(results)
            
            self.connection.commit()
            
            # Update statistics
            for result in results:
                if result[&amp;#39;inserted&amp;#39;]:
                    self.stats[&amp;#39;inserted&amp;#39;] += 1
                else:
                    self.stats[&amp;#39;updated&amp;#39;] += 1
                    
        except Exception as e:
            self.stats[&amp;#39;errors&amp;#39;] += len(self.batch_items)
            self.logger.error(f&amp;quot;Error processing batch: {e}&amp;quot;)
            self.connection.rollback()
        
        finally:
            self.batch_items.clear()
    
    def _insert_price_history(self, results: List[Dict]):
        &amp;quot;&amp;quot;&amp;quot;Insert price history records&amp;quot;&amp;quot;&amp;quot;
        price_history_sql = &amp;quot;&amp;quot;&amp;quot;
        INSERT INTO price_history (product_id, price, original_price)
        VALUES (%s, %s, %s)
        &amp;quot;&amp;quot;&amp;quot;
        
        price_history_data = []
        for i, result in enumerate(results):
            item = self.batch_items[i]
            if item.get(&amp;#39;price&amp;#39;):
                price_history_data.append((
                    result[&amp;#39;id&amp;#39;],
                    item[&amp;#39;price&amp;#39;],
                    item.get(&amp;#39;original_price&amp;#39;)
                ))
        
        if price_history_data:
            self.cursor.executemany(price_history_sql, price_history_data)
    
    def close_spider(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Process remaining items and close connection&amp;quot;&amp;quot;&amp;quot;
        # Process remaining batch
        self._process_batch()
        
        # Update categories and brands tables
        self._update_reference_tables()
        
        # Log statistics
        self.logger.info(&amp;quot;=== PostgreSQL Pipeline Statistics ===&amp;quot;)
        for stat, count in self.stats.items():
            self.logger.info(f&amp;quot;{stat.capitalize()}: {count}&amp;quot;)
        
        # Close connection
        if self.cursor:
            self.cursor.close()
        if self.connection:
            self.connection.close()
    
    def _update_reference_tables(self):
        &amp;quot;&amp;quot;&amp;quot;Update categories and brands reference tables&amp;quot;&amp;quot;&amp;quot;
        try:
            # Update categories
            self.cursor.execute(&amp;quot;&amp;quot;&amp;quot;
                INSERT INTO categories (name)
                SELECT DISTINCT category
                FROM products
                WHERE category IS NOT NULL
                ON CONFLICT (name) DO NOTHING;
            &amp;quot;&amp;quot;&amp;quot;)
            
            # Update brands
            self.cursor.execute(&amp;quot;&amp;quot;&amp;quot;
                INSERT INTO brands (name)
                SELECT DISTINCT brand
                FROM products
                WHERE brand IS NOT NULL
                ON CONFLICT (name) DO NOTHING;
            &amp;quot;&amp;quot;&amp;quot;)
            
            self.connection.commit()
            
        except Exception as e:
            self.logger.error(f&amp;quot;Error updating reference tables: {e}&amp;quot;)
            self.connection.rollback()

class PostgreSQLAnalytics:
    &amp;quot;&amp;quot;&amp;quot;Advanced analytics queries for PostgreSQL&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, postgres_settings):
        self.connection = psycopg2.connect(**postgres_settings)
        self.cursor = self.connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
    
    def search_products(self, query: str, limit: int = 50) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Full-text search for products&amp;quot;&amp;quot;&amp;quot;
        search_sql = &amp;quot;&amp;quot;&amp;quot;
        SELECT id, name, brand, category, price, rating, review_count, url,
               ts_rank(to_tsvector(&amp;#39;english&amp;#39;, name || &amp;#39; &amp;#39; || COALESCE(description, &amp;#39;&amp;#39;)), 
                      plainto_tsquery(&amp;#39;english&amp;#39;, %s)) AS rank
        FROM products
        WHERE to_tsvector(&amp;#39;english&amp;#39;, name || &amp;#39; &amp;#39; || COALESCE(description, &amp;#39;&amp;#39;)) 
              @@ plainto_tsquery(&amp;#39;english&amp;#39;, %s)
        ORDER BY rank DESC, rating DESC NULLS LAST
        LIMIT %s;
        &amp;quot;&amp;quot;&amp;quot;
        
        self.cursor.execute(search_sql, (query, query, limit))
        return self.cursor.fetchall()
    
    def get_price_trends(self, product_id: int, days: int = 30) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Get price trends for a specific product&amp;quot;&amp;quot;&amp;quot;
        trends_sql = &amp;quot;&amp;quot;&amp;quot;
        SELECT DATE(scraped_at) as date, 
               AVG(price) as avg_price,
               MIN(price) as min_price,
               MAX(price) as max_price,
               COUNT(*) as data_points
        FROM price_history
        WHERE product_id = %s 
          AND scraped_at &amp;gt;= CURRENT_DATE - INTERVAL &amp;#39;%s days&amp;#39;
        GROUP BY DATE(scraped_at)
        ORDER BY date;
        &amp;quot;&amp;quot;&amp;quot;
        
        self.cursor.execute(trends_sql, (product_id, days))
        return self.cursor.fetchall()
    
    def get_category_insights(self) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Get insights by category&amp;quot;&amp;quot;&amp;quot;
        insights_sql = &amp;quot;&amp;quot;&amp;quot;
        SELECT category,
               COUNT(*) as product_count,
               AVG(price) as avg_price,
               MIN(price) as min_price,
               MAX(price) as max_price,
               AVG(rating) as avg_rating,
               COUNT(CASE WHEN in_stock THEN 1 END) as in_stock_count,
               AVG(data_quality_score) as avg_quality_score
        FROM products
        WHERE category IS NOT NULL
        GROUP BY category
        ORDER BY product_count DESC;
        &amp;quot;&amp;quot;&amp;quot;
        
        self.cursor.execute(insights_sql)
        return self.cursor.fetchall()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Next Steps&lt;/h2&gt;
&lt;p&gt;That covers the data layer: validation, enrichment, and two storage backends with deduplication and batch upserts. Before moving on, a useful exercise is to wire both pipelines into the spider from Part 3 and check the quality scores it produces on real data. &lt;a href=&quot;/tutorials/web-scraping-scrapy-part-5&quot;&gt;Part 5: Production Deployment&lt;/a&gt; covers Docker, Kubernetes, CI/CD, and monitoring.&lt;/p&gt;
</content:encoded></item><item><title>Scrapy, part 3: staying undetected at scale</title><link>https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-3/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-3/</guid><description>Proxies, fingerprints, and distributed crawls: running scrapers across millions of pages without getting blocked.</description><pubDate>Mon, 23 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Anti-detection and scaling&lt;/h1&gt;
&lt;p&gt;This part is about running crawls that survive contact with real anti-bot systems: fingerprint-consistent headers, proxy rotation with health checks, distributed workers on Scrapy-Redis, and the monitoring and compliance layers around all of it.&lt;/p&gt;
&lt;h2&gt;What this part covers&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Advanced anti-detection and stealth techniques&lt;/li&gt;
&lt;li&gt;Distributed scraping with Scrapy-Redis&lt;/li&gt;
&lt;li&gt;IP rotation and proxy management&lt;/li&gt;
&lt;li&gt;Browser fingerprinting avoidance&lt;/li&gt;
&lt;li&gt;Rate limiting and adaptive throttling&lt;/li&gt;
&lt;li&gt;Monitoring, alerting, and health checks&lt;/li&gt;
&lt;li&gt;Legal compliance and ethical scraping&lt;/li&gt;
&lt;li&gt;Performance optimization and scaling&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Anti-Detection Techniques&lt;/h2&gt;
&lt;p&gt;Detection systems look at far more than your user agent: request timing, header order, and fingerprint consistency all get checked. Here&amp;#39;s how to stay under the radar:&lt;/p&gt;
&lt;h3&gt;Stealth Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/settings.py - Advanced Stealth Settings

# Respect robots.txt but with custom delay
ROBOTSTXT_OBEY = True
ROBOTSTXT_USER_AGENT = &amp;#39;*&amp;#39;

# Realistic browser behavior
DOWNLOAD_DELAY = 3  # Base delay between requests
RANDOMIZE_DOWNLOAD_DELAY = True  # 0.5 * to 1.5 * DOWNLOAD_DELAY
DOWNLOAD_TIMEOUT = 30
DOWNLOAD_MAXSIZE = 1073741824  # 1GB
DOWNLOAD_WARNSIZE = 33554432   # 32MB

# Advanced AutoThrottle settings
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 1
AUTOTHROTTLE_MAX_DELAY = 60
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0  # Conservative for stealth
AUTOTHROTTLE_DEBUG = False

# Realistic request patterns
CONCURRENT_REQUESTS = 8
CONCURRENT_REQUESTS_PER_DOMAIN = 2

# Advanced cookie handling
COOKIES_ENABLED = True
COOKIES_DEBUG = False

# Memory and resource management
MEMUSAGE_ENABLED = True
MEMUSAGE_LIMIT_MB = 2048
MEMUSAGE_WARNING_MB = 1024

# DNS timeout
DNSCACHE_ENABLED = True
DNSCACHE_SIZE = 10000
DNS_TIMEOUT = 60

# Enable telnet console for debugging (disable in production)
TELNETCONSOLE_ENABLED = False

# Custom middleware stack for stealth
DOWNLOADER_MIDDLEWARES = {
    &amp;#39;webscraper.middlewares.StealthUserAgentMiddleware&amp;#39;: 400,
    &amp;#39;webscraper.middlewares.ProxyRotationMiddleware&amp;#39;: 410,
    &amp;#39;webscraper.middlewares.HeaderSpoofingMiddleware&amp;#39;: 420,
    &amp;#39;webscraper.middlewares.CookiePersistenceMiddleware&amp;#39;: 430,
    &amp;#39;webscraper.middlewares.RequestTimingMiddleware&amp;#39;: 440,
    &amp;#39;webscraper.middlewares.FingerprintResistanceMiddleware&amp;#39;: 450,
    &amp;#39;scrapy.downloadermiddlewares.retry.RetryMiddleware&amp;#39;: 500,
    &amp;#39;scrapy.downloadermiddlewares.useragent.UserAgentMiddleware&amp;#39;: None,  # Disabled
}

# Anti-detection headers
DEFAULT_REQUEST_HEADERS = {
    &amp;#39;Accept&amp;#39;: &amp;#39;text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7&amp;#39;,
    &amp;#39;Accept-Language&amp;#39;: &amp;#39;en-US,en;q=0.9&amp;#39;,
    &amp;#39;Accept-Encoding&amp;#39;: &amp;#39;gzip, deflate, br&amp;#39;,
    &amp;#39;DNT&amp;#39;: &amp;#39;1&amp;#39;,
    &amp;#39;Connection&amp;#39;: &amp;#39;keep-alive&amp;#39;,
    &amp;#39;Upgrade-Insecure-Requests&amp;#39;: &amp;#39;1&amp;#39;,
    &amp;#39;Sec-Fetch-Dest&amp;#39;: &amp;#39;document&amp;#39;,
    &amp;#39;Sec-Fetch-Mode&amp;#39;: &amp;#39;navigate&amp;#39;,
    &amp;#39;Sec-Fetch-Site&amp;#39;: &amp;#39;none&amp;#39;,
    &amp;#39;Sec-Fetch-User&amp;#39;: &amp;#39;?1&amp;#39;,
    &amp;#39;Cache-Control&amp;#39;: &amp;#39;max-age=0&amp;#39;,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Advanced Stealth Middleware&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/middlewares.py
import random
import time
import json
import hashlib
from datetime import datetime, timedelta
from scrapy.downloadermiddlewares.useragent import UserAgentMiddleware
from scrapy import signals
from scrapy.exceptions import IgnoreRequest

class StealthUserAgentMiddleware(UserAgentMiddleware):
    &amp;quot;&amp;quot;&amp;quot;Advanced user agent rotation with browser consistency&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        # Browser families with consistent headers
        self.browser_profiles = {
            &amp;#39;chrome&amp;#39;: {
                &amp;#39;user_agents&amp;#39;: [
                    &amp;#39;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&amp;#39;,
                    &amp;#39;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&amp;#39;,
                    &amp;#39;Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&amp;#39;,
                ],
                &amp;#39;sec_ch_ua&amp;#39;: &amp;#39;&amp;quot;Not_A Brand&amp;quot;;v=&amp;quot;8&amp;quot;, &amp;quot;Chromium&amp;quot;;v=&amp;quot;120&amp;quot;, &amp;quot;Google Chrome&amp;quot;;v=&amp;quot;120&amp;quot;&amp;#39;,
                &amp;#39;sec_ch_ua_mobile&amp;#39;: &amp;#39;?0&amp;#39;,
                &amp;#39;sec_ch_ua_platform&amp;#39;: &amp;#39;&amp;quot;Windows&amp;quot;&amp;#39;,
            },
            &amp;#39;firefox&amp;#39;: {
                &amp;#39;user_agents&amp;#39;: [
                    &amp;#39;Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0&amp;#39;,
                    &amp;#39;Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0&amp;#39;,
                    &amp;#39;Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0&amp;#39;,
                ],
            },
            &amp;#39;safari&amp;#39;: {
                &amp;#39;user_agents&amp;#39;: [
                    &amp;#39;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15&amp;#39;,
                    &amp;#39;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15&amp;#39;,
                ],
            }
        }
        self.current_profile = None
        self.session_ua = None
    
    def process_request(self, request, spider):
        # Maintain consistent user agent per session
        if not self.session_ua:
            browser = random.choice(list(self.browser_profiles.keys()))
            self.current_profile = self.browser_profiles[browser]
            self.session_ua = random.choice(self.current_profile[&amp;#39;user_agents&amp;#39;])
        
        request.headers[&amp;#39;User-Agent&amp;#39;] = self.session_ua
        
        # Add browser-specific headers
        if &amp;#39;chrome&amp;#39; in self.session_ua.lower():
            profile = self.browser_profiles[&amp;#39;chrome&amp;#39;]
            request.headers[&amp;#39;sec-ch-ua&amp;#39;] = profile[&amp;#39;sec_ch_ua&amp;#39;]
            request.headers[&amp;#39;sec-ch-ua-mobile&amp;#39;] = profile[&amp;#39;sec_ch_ua_mobile&amp;#39;]
            request.headers[&amp;#39;sec-ch-ua-platform&amp;#39;] = profile[&amp;#39;sec_ch_ua_platform&amp;#39;]

class HeaderSpoofingMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Spoof headers to mimic real browser behavior&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.session_id = hashlib.md5(str(time.time()).encode()).hexdigest()[:16]
        self.request_count = 0
    
    def process_request(self, request, spider):
        self.request_count += 1
        
        # Add realistic timing headers
        request.headers[&amp;#39;X-Request-ID&amp;#39;] = f&amp;quot;{self.session_id}-{self.request_count}&amp;quot;
        
        # Randomize header order
        headers_to_randomize = [
            &amp;#39;Accept-Encoding&amp;#39;,
            &amp;#39;Accept-Language&amp;#39;, 
            &amp;#39;Cache-Control&amp;#39;,
            &amp;#39;Connection&amp;#39;,
            &amp;#39;DNT&amp;#39;,
            &amp;#39;Upgrade-Insecure-Requests&amp;#39;
        ]
        
        # Occasionally omit some headers to appear more human
        if random.random() &amp;lt; 0.1:  # 10% chance
            header_to_remove = random.choice(headers_to_randomize)
            if header_to_remove in request.headers:
                del request.headers[header_to_remove]
        
        # Add referer for non-start URLs
        if not request.meta.get(&amp;#39;is_start_url&amp;#39;, False):
            if &amp;#39;Referer&amp;#39; not in request.headers:
                # Use the domain as referer
                domain = request.url.split(&amp;#39;/&amp;#39;)[2]
                request.headers[&amp;#39;Referer&amp;#39;] = f&amp;quot;https://{domain}/&amp;quot;

class RequestTimingMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Implement realistic request timing patterns&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.last_request_time = {}
        self.human_patterns = [
            # Reading pattern: quick succession then pause
            [0.5, 0.3, 0.8, 5.0],
            # Browsing pattern: varied intervals
            [1.2, 2.1, 0.7, 3.5, 1.8],
            # Search pattern: quick then slow
            [0.4, 0.6, 8.0, 2.0],
        ]
        self.current_pattern = []
        self.pattern_index = 0
    
    def process_request(self, request, spider):
        domain = request.url.split(&amp;#39;/&amp;#39;)[2]
        current_time = time.time()
        
        # Get or initialize timing for this domain
        if domain not in self.last_request_time:
            self.last_request_time[domain] = current_time
            return
        
        # Calculate time since last request to this domain
        time_since_last = current_time - self.last_request_time[domain]
        
        # Choose a human-like delay pattern
        if not self.current_pattern:
            self.current_pattern = random.choice(self.human_patterns).copy()
            self.pattern_index = 0
        
        target_delay = self.current_pattern[self.pattern_index]
        self.pattern_index = (self.pattern_index + 1) % len(self.current_pattern)
        
        # Apply delay if needed
        if time_since_last &amp;lt; target_delay:
            sleep_time = target_delay - time_since_last
            spider.logger.debug(f&amp;#39;Applying human-like delay: {sleep_time:.2f}s for {domain}&amp;#39;)
            time.sleep(sleep_time)
        
        self.last_request_time[domain] = time.time()

class FingerprintResistanceMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Resist browser fingerprinting attempts&amp;quot;&amp;quot;&amp;quot;
    
    def process_request(self, request, spider):
        # Randomize Accept header slightly
        accept_variations = [
            &amp;#39;text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8&amp;#39;,
            &amp;#39;text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8&amp;#39;,
            &amp;#39;text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8&amp;#39;,
        ]
        
        if random.random() &amp;lt; 0.3:  # 30% chance to vary
            request.headers[&amp;#39;Accept&amp;#39;] = random.choice(accept_variations)
        
        # Randomize Accept-Language
        lang_variations = [
            &amp;#39;en-US,en;q=0.9&amp;#39;,
            &amp;#39;en-US,en;q=0.8&amp;#39;,
            &amp;#39;en-US,en;q=0.9,es;q=0.8&amp;#39;,
            &amp;#39;en-US,en;q=0.5&amp;#39;,
        ]
        
        if random.random() &amp;lt; 0.2:  # 20% chance to vary
            request.headers[&amp;#39;Accept-Language&amp;#39;] = random.choice(lang_variations)

class CookiePersistenceMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Advanced cookie management for session consistency&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.session_cookies = {}
    
    def process_response(self, request, response, spider):
        # Store cookies for this domain
        domain = request.url.split(&amp;#39;/&amp;#39;)[2]
        cookies = response.headers.getlist(&amp;#39;Set-Cookie&amp;#39;)
        
        if cookies:
            if domain not in self.session_cookies:
                self.session_cookies[domain] = {}
            
            for cookie in cookies:
                cookie_str = cookie.decode(&amp;#39;utf-8&amp;#39;)
                if &amp;#39;=&amp;#39; in cookie_str:
                    name, value = cookie_str.split(&amp;#39;=&amp;#39;, 1)
                    # Store only the name=value part
                    value = value.split(&amp;#39;;&amp;#39;)[0]
                    self.session_cookies[domain][name] = value
        
        return response
    
    def process_request(self, request, spider):
        domain = request.url.split(&amp;#39;/&amp;#39;)[2]
        
        # Add stored cookies for this domain
        if domain in self.session_cookies:
            cookie_header = &amp;#39;; &amp;#39;.join([
                f&amp;quot;{name}={value}&amp;quot; 
                for name, value in self.session_cookies[domain].items()
            ])
            if cookie_header:
                request.headers[&amp;#39;Cookie&amp;#39;] = cookie_header
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Proxy Rotation System&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/proxy_manager.py
import random
import time
import requests
from typing import List, Dict, Optional
import threading
from dataclasses import dataclass
from enum import Enum

class ProxyStatus(Enum):
    ACTIVE = &amp;quot;active&amp;quot;
    FAILED = &amp;quot;failed&amp;quot;
    RATE_LIMITED = &amp;quot;rate_limited&amp;quot;
    TESTING = &amp;quot;testing&amp;quot;

@dataclass
class ProxyInfo:
    host: str
    port: int
    username: Optional[str] = None
    password: Optional[str] = None
    protocol: str = &amp;quot;http&amp;quot;
    status: ProxyStatus = ProxyStatus.TESTING
    success_count: int = 0
    failure_count: int = 0
    last_used: Optional[float] = None
    response_time: Optional[float] = None
    rate_limit_until: Optional[float] = None
    
    @property
    def url(self) -&amp;gt; str:
        if self.username and self.password:
            return f&amp;quot;{self.protocol}://{self.username}:{self.password}@{self.host}:{self.port}&amp;quot;
        return f&amp;quot;{self.protocol}://{self.host}:{self.port}&amp;quot;
    
    @property
    def success_rate(self) -&amp;gt; float:
        total = self.success_count + self.failure_count
        return self.success_count / total if total &amp;gt; 0 else 0.0
    
    def is_available(self) -&amp;gt; bool:
        if self.status == ProxyStatus.FAILED:
            return False
        if self.rate_limit_until and time.time() &amp;lt; self.rate_limit_until:
            return False
        return True

class ProxyManager:
    &amp;quot;&amp;quot;&amp;quot;Advanced proxy rotation and health management&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, proxy_list: List[Dict], health_check_interval: int = 300):
        self.proxies = [ProxyInfo(**proxy) for proxy in proxy_list]
        self.health_check_interval = health_check_interval
        self.last_health_check = 0
        self.lock = threading.Lock()
        
        # Start health checking thread
        self.health_thread = threading.Thread(target=self._health_check_loop, daemon=True)
        self.health_thread.start()
    
    def get_proxy(self) -&amp;gt; Optional[ProxyInfo]:
        &amp;quot;&amp;quot;&amp;quot;Get the best available proxy&amp;quot;&amp;quot;&amp;quot;
        with self.lock:
            available_proxies = [p for p in self.proxies if p.is_available()]
            
            if not available_proxies:
                return None
            
            # Sort by success rate and response time
            available_proxies.sort(
                key=lambda p: (p.success_rate, -p.response_time or 0),
                reverse=True
            )
            
            # Weighted selection favoring better proxies
            weights = [max(0.1, p.success_rate) for p in available_proxies]
            proxy = random.choices(available_proxies, weights=weights)[0]
            
            proxy.last_used = time.time()
            return proxy
    
    def report_success(self, proxy: ProxyInfo, response_time: float = None):
        &amp;quot;&amp;quot;&amp;quot;Report successful proxy usage&amp;quot;&amp;quot;&amp;quot;
        with self.lock:
            proxy.success_count += 1
            proxy.status = ProxyStatus.ACTIVE
            if response_time:
                proxy.response_time = response_time
    
    def report_failure(self, proxy: ProxyInfo, is_rate_limit: bool = False):
        &amp;quot;&amp;quot;&amp;quot;Report proxy failure&amp;quot;&amp;quot;&amp;quot;
        with self.lock:
            proxy.failure_count += 1
            
            if is_rate_limit:
                proxy.status = ProxyStatus.RATE_LIMITED
                proxy.rate_limit_until = time.time() + 300  # 5 minute cooldown
            elif proxy.failure_count &amp;gt; 5:
                proxy.status = ProxyStatus.FAILED
    
    def _health_check_loop(self):
        &amp;quot;&amp;quot;&amp;quot;Background thread for proxy health checking&amp;quot;&amp;quot;&amp;quot;
        while True:
            time.sleep(self.health_check_interval)
            self._perform_health_checks()
    
    def _perform_health_checks(self):
        &amp;quot;&amp;quot;&amp;quot;Check health of all proxies&amp;quot;&amp;quot;&amp;quot;
        test_url = &amp;quot;http://httpbin.org/ip&amp;quot;
        
        for proxy in self.proxies:
            if proxy.status == ProxyStatus.FAILED:
                continue
                
            try:
                start_time = time.time()
                response = requests.get(
                    test_url,
                    proxies={&amp;quot;http&amp;quot;: proxy.url, &amp;quot;https&amp;quot;: proxy.url},
                    timeout=10
                )
                response_time = time.time() - start_time
                
                if response.status_code == 200:
                    self.report_success(proxy, response_time)
                else:
                    self.report_failure(proxy)
                    
            except Exception:
                self.report_failure(proxy)
    
    def get_stats(self) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Get proxy pool statistics&amp;quot;&amp;quot;&amp;quot;
        with self.lock:
            total = len(self.proxies)
            active = sum(1 for p in self.proxies if p.status == ProxyStatus.ACTIVE)
            failed = sum(1 for p in self.proxies if p.status == ProxyStatus.FAILED)
            rate_limited = sum(1 for p in self.proxies if p.status == ProxyStatus.RATE_LIMITED)
            
            return {
                &amp;#39;total&amp;#39;: total,
                &amp;#39;active&amp;#39;: active,
                &amp;#39;failed&amp;#39;: failed,
                &amp;#39;rate_limited&amp;#39;: rate_limited,
                &amp;#39;success_rate&amp;#39;: sum(p.success_rate for p in self.proxies) / total if total &amp;gt; 0 else 0
            }

# Proxy rotation middleware
class AdvancedProxyRotationMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Advanced proxy rotation with health management&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, proxy_list):
        self.proxy_manager = ProxyManager(proxy_list)
    
    @classmethod
    def from_crawler(cls, crawler):
        proxy_list = crawler.settings.get(&amp;#39;PROXY_LIST&amp;#39;, [])
        return cls(proxy_list)
    
    def process_request(self, request, spider):
        proxy = self.proxy_manager.get_proxy()
        
        if proxy:
            request.meta[&amp;#39;proxy&amp;#39;] = proxy.url
            request.meta[&amp;#39;proxy_info&amp;#39;] = proxy
            spider.logger.debug(f&amp;#39;Using proxy: {proxy.host}:{proxy.port}&amp;#39;)
        else:
            spider.logger.warning(&amp;#39;No available proxies&amp;#39;)
    
    def process_response(self, request, response, spider):
        proxy_info = request.meta.get(&amp;#39;proxy_info&amp;#39;)
        
        if proxy_info:
            if response.status == 200:
                self.proxy_manager.report_success(proxy_info)
            elif response.status == 429:  # Rate limited
                self.proxy_manager.report_failure(proxy_info, is_rate_limit=True)
            elif response.status &amp;gt;= 400:
                self.proxy_manager.report_failure(proxy_info)
        
        return response
    
    def process_exception(self, request, exception, spider):
        proxy_info = request.meta.get(&amp;#39;proxy_info&amp;#39;)
        
        if proxy_info:
            self.proxy_manager.report_failure(proxy_info)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Distributed Scraping with Scrapy-Redis&lt;/h2&gt;
&lt;p&gt;When one machine isn&amp;#39;t enough, Scrapy-Redis coordinates multiple workers through a shared queue:&lt;/p&gt;
&lt;h3&gt;Scrapy-Redis Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install Scrapy-Redis
pip install scrapy-redis

# Start Redis server
redis-server

# Or using Docker
docker run -d -p 6379:6379 redis:alpine
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Distributed Spider Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/settings.py - Redis Configuration

# Enable Scrapy-Redis
SCHEDULER = &amp;quot;scrapy_redis.scheduler.Scheduler&amp;quot;
DUPEFILTER_CLASS = &amp;quot;scrapy_redis.dupefilter.RFPDupeFilter&amp;quot;
ITEM_PIPELINES = {
    &amp;#39;scrapy_redis.pipelines.RedisPipeline&amp;#39;: 300,
    &amp;#39;webscraper.pipelines.ValidationPipeline&amp;#39;: 400,
}

# Redis connection
REDIS_URL = &amp;#39;redis://localhost:6379&amp;#39;

# Or with detailed configuration
REDIS_PARAMS = {
    &amp;#39;host&amp;#39;: &amp;#39;localhost&amp;#39;,
    &amp;#39;port&amp;#39;: 6379,
    &amp;#39;db&amp;#39;: 0,
    &amp;#39;password&amp;#39;: None,
}

# Scheduler configuration
SCHEDULER_PERSIST = True  # Keep scheduler data after spider closes
SCHEDULER_QUEUE_CLASS = &amp;#39;scrapy_redis.queue.PriorityQueue&amp;#39;

# Request serialization
SCHEDULER_SERIALIZER = &amp;quot;scrapy_redis.picklecompat&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Distributed Spider&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/spiders/distributed_spider.py
from scrapy_redis.spiders import RedisSpider
from webscraper.items import ProductItem
from scrapy.loader import ItemLoader
import json

class DistributedEcommerceSpider(RedisSpider):
    &amp;quot;&amp;quot;&amp;quot;Distributed spider using Redis for coordination&amp;quot;&amp;quot;&amp;quot;
    
    name = &amp;#39;distributed_ecommerce&amp;#39;
    redis_key = &amp;#39;distributed_ecommerce:start_urls&amp;#39;
    
    custom_settings = {
        &amp;#39;CONCURRENT_REQUESTS&amp;#39;: 32,
        &amp;#39;CONCURRENT_REQUESTS_PER_DOMAIN&amp;#39;: 16,
        &amp;#39;DOWNLOAD_DELAY&amp;#39;: 1,
        &amp;#39;SCHEDULER_IDLE_BEFORE_CLOSE&amp;#39;: 10,  # Wait 10 seconds before closing
    }
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.processed_count = 0
        self.error_count = 0
    
    def parse(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse category pages and product listings&amp;quot;&amp;quot;&amp;quot;
        self.logger.info(f&amp;#39;Worker {self.name} processing: {response.url}&amp;#39;)
        
        # Extract product links
        product_links = response.css(&amp;#39;.product-item a::attr(href)&amp;#39;).getall()
        
        for link in product_links:
            product_url = response.urljoin(link)
            yield response.follow(
                product_url,
                callback=self.parse_product,
                meta={&amp;#39;category_url&amp;#39;: response.url}
            )
        
        # Extract pagination
        next_page = response.css(&amp;#39;.pagination .next::attr(href)&amp;#39;).get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)
        
        # Add more category pages to Redis queue
        self.discover_more_urls(response)
    
    def parse_product(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse individual product pages&amp;quot;&amp;quot;&amp;quot;
        try:
            loader = ItemLoader(item=ProductItem(), response=response)
            
            loader.add_css(&amp;#39;name&amp;#39;, &amp;#39;h1.product-title::text&amp;#39;)
            loader.add_css(&amp;#39;price&amp;#39;, &amp;#39;.price-current::text&amp;#39;)
            loader.add_css(&amp;#39;description&amp;#39;, &amp;#39;.product-description::text&amp;#39;)
            loader.add_css(&amp;#39;brand&amp;#39;, &amp;#39;.brand-name::text&amp;#39;)
            loader.add_css(&amp;#39;rating&amp;#39;, &amp;#39;.rating-value::text&amp;#39;)
            
            # Add metadata
            loader.add_value(&amp;#39;url&amp;#39;, response.url)
            loader.add_value(&amp;#39;category_url&amp;#39;, response.meta.get(&amp;#39;category_url&amp;#39;))
            loader.add_value(&amp;#39;worker_id&amp;#39;, self.name)
            loader.add_value(&amp;#39;scraped_at&amp;#39;, self.get_current_time())
            
            item = loader.load_item()
            self.processed_count += 1
            
            if self.processed_count % 100 == 0:
                self.logger.info(f&amp;#39;Worker {self.name} processed {self.processed_count} products&amp;#39;)
            
            yield item
            
        except Exception as e:
            self.error_count += 1
            self.logger.error(f&amp;#39;Error parsing product {response.url}: {e}&amp;#39;)
    
    def discover_more_urls(self, response):
        &amp;quot;&amp;quot;&amp;quot;Discover and add more URLs to the Redis queue&amp;quot;&amp;quot;&amp;quot;
        # Find category links
        category_links = response.css(&amp;#39;.category-nav a::attr(href)&amp;#39;).getall()
        
        for link in category_links:
            category_url = response.urljoin(link)
            # Add to Redis queue for other workers
            self.server.lpush(
                f&amp;#39;{self.redis_key}:discovered&amp;#39;,
                json.dumps({&amp;#39;url&amp;#39;: category_url, &amp;#39;priority&amp;#39;: 1})
            )
    
    def get_current_time(self):
        from datetime import datetime
        return datetime.now().isoformat()
    
    def closed(self, reason):
        &amp;quot;&amp;quot;&amp;quot;Spider closing callback&amp;quot;&amp;quot;&amp;quot;
        self.logger.info(f&amp;#39;Worker {self.name} closing: {reason}&amp;#39;)
        self.logger.info(f&amp;#39;Processed: {self.processed_count}, Errors: {self.error_count}&amp;#39;)

# Master coordinator spider
class CoordinatorSpider(RedisSpider):
    &amp;quot;&amp;quot;&amp;quot;Coordinator spider that feeds URLs to workers&amp;quot;&amp;quot;&amp;quot;
    
    name = &amp;#39;coordinator&amp;#39;
    redis_key = &amp;#39;distributed_ecommerce:start_urls&amp;#39;
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.seed_urls()
    
    def seed_urls(self):
        &amp;quot;&amp;quot;&amp;quot;Seed initial URLs into Redis&amp;quot;&amp;quot;&amp;quot;
        initial_urls = [
            &amp;#39;https://example-store.com/categories/electronics&amp;#39;,
            &amp;#39;https://example-store.com/categories/clothing&amp;#39;,
            &amp;#39;https://example-store.com/categories/home&amp;#39;,
            &amp;#39;https://example-store.com/categories/books&amp;#39;,
        ]
        
        for url in initial_urls:
            self.server.lpush(self.redis_key, url)
        
        self.logger.info(f&amp;#39;Seeded {len(initial_urls)} URLs&amp;#39;)
    
    def parse(self, response):
        &amp;quot;&amp;quot;&amp;quot;This spider doesn&amp;#39;t parse, it just coordinates&amp;quot;&amp;quot;&amp;quot;
        pass
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Worker Management Script&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# scripts/worker_manager.py
import subprocess
import sys
import time
import signal
import json
import redis
from typing import List, Dict

class WorkerManager:
    &amp;quot;&amp;quot;&amp;quot;Manage distributed scraping workers&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, redis_url: str = &amp;#39;redis://localhost:6379&amp;#39;):
        self.redis_client = redis.from_url(redis_url)
        self.workers: List[subprocess.Popen] = []
        self.running = True
        
        # Register signal handlers
        signal.signal(signal.SIGINT, self.shutdown_handler)
        signal.signal(signal.SIGTERM, self.shutdown_handler)
    
    def start_workers(self, spider_name: str, num_workers: int = 4):
        &amp;quot;&amp;quot;&amp;quot;Start multiple worker processes&amp;quot;&amp;quot;&amp;quot;
        print(f&amp;quot;Starting {num_workers} workers for spider &amp;#39;{spider_name}&amp;#39;&amp;quot;)
        
        for i in range(num_workers):
            worker_id = f&amp;quot;{spider_name}_worker_{i}&amp;quot;
            cmd = [
                &amp;#39;scrapy&amp;#39;, &amp;#39;crawl&amp;#39;, spider_name,
                &amp;#39;-s&amp;#39;, f&amp;#39;BOT_NAME={worker_id}&amp;#39;,
                &amp;#39;-L&amp;#39;, &amp;#39;INFO&amp;#39;
            ]
            
            worker = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True
            )
            
            self.workers.append(worker)
            print(f&amp;quot;Started worker {worker_id} (PID: {worker.pid})&amp;quot;)
    
    def monitor_workers(self):
        &amp;quot;&amp;quot;&amp;quot;Monitor worker health and restart if needed&amp;quot;&amp;quot;&amp;quot;
        while self.running:
            for i, worker in enumerate(self.workers):
                if worker.poll() is not None:  # Worker has terminated
                    print(f&amp;quot;Worker {i} terminated with code {worker.returncode}&amp;quot;)
                    
                    # Restart worker if it crashed
                    if worker.returncode != 0:
                        print(f&amp;quot;Restarting worker {i}&amp;quot;)
                        # Implementation for restarting worker
                        pass
            
            # Monitor Redis queue sizes
            self.print_queue_stats()
            
            time.sleep(30)  # Check every 30 seconds
    
    def print_queue_stats(self):
        &amp;quot;&amp;quot;&amp;quot;Print Redis queue statistics&amp;quot;&amp;quot;&amp;quot;
        try:
            queue_size = self.redis_client.llen(&amp;#39;distributed_ecommerce:start_urls&amp;#39;)
            processing = self.redis_client.scard(&amp;#39;distributed_ecommerce:dupefilter&amp;#39;)
            
            print(f&amp;quot;Queue size: {queue_size}, Processed: {processing}&amp;quot;)
            
        except Exception as e:
            print(f&amp;quot;Error getting Redis stats: {e}&amp;quot;)
    
    def shutdown_handler(self, signum, frame):
        &amp;quot;&amp;quot;&amp;quot;Handle shutdown signals&amp;quot;&amp;quot;&amp;quot;
        print(&amp;quot;\nShutting down workers...&amp;quot;)
        self.running = False
        
        for i, worker in enumerate(self.workers):
            print(f&amp;quot;Terminating worker {i}&amp;quot;)
            worker.terminate()
            
            # Wait for graceful shutdown
            try:
                worker.wait(timeout=10)
            except subprocess.TimeoutExpired:
                print(f&amp;quot;Force killing worker {i}&amp;quot;)
                worker.kill()
        
        print(&amp;quot;All workers shut down&amp;quot;)
        sys.exit(0)
    
    def run(self, spider_name: str, num_workers: int = 4):
        &amp;quot;&amp;quot;&amp;quot;Main execution method&amp;quot;&amp;quot;&amp;quot;
        self.start_workers(spider_name, num_workers)
        self.monitor_workers()

if __name__ == &amp;quot;__main__&amp;quot;:
    if len(sys.argv) &amp;lt; 2:
        print(&amp;quot;Usage: python worker_manager.py &amp;lt;spider_name&amp;gt; [num_workers]&amp;quot;)
        sys.exit(1)
    
    spider_name = sys.argv[1]
    num_workers = int(sys.argv[2]) if len(sys.argv) &amp;gt; 2 else 4
    
    manager = WorkerManager()
    manager.run(spider_name, num_workers)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Monitoring and Alerting&lt;/h2&gt;
&lt;h3&gt;Monitoring System&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/monitoring.py
import time
import json
import smtplib
from datetime import datetime, timedelta
from email.mime.text import MimeText
from email.mime.multipart import MimeMultipart
from typing import Dict, List, Optional
import psutil
import redis
from dataclasses import dataclass, asdict

@dataclass
class ScrapingMetrics:
    timestamp: str
    spider_name: str
    pages_scraped: int
    items_extracted: int
    errors: int
    response_time_avg: float
    memory_usage_mb: float
    cpu_usage_percent: float
    active_requests: int
    queue_size: int
    success_rate: float

class PerformanceMonitor:
    &amp;quot;&amp;quot;&amp;quot;Monitor scraping performance and system resources&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, redis_url: str = &amp;#39;redis://localhost:6379&amp;#39;):
        self.redis_client = redis.from_url(redis_url)
        self.metrics_history: List[ScrapingMetrics] = []
        self.alert_thresholds = {
            &amp;#39;error_rate&amp;#39;: 0.1,      # 10% error rate
            &amp;#39;memory_usage&amp;#39;: 2048,    # 2GB memory usage
            &amp;#39;cpu_usage&amp;#39;: 80,         # 80% CPU usage
            &amp;#39;response_time&amp;#39;: 10,     # 10 second average response time
            &amp;#39;queue_stall&amp;#39;: 300,      # 5 minutes without queue progress
        }
        self.last_queue_size = 0
        self.queue_stall_start = None
    
    def collect_metrics(self, spider_name: str) -&amp;gt; ScrapingMetrics:
        &amp;quot;&amp;quot;&amp;quot;Collect current performance metrics&amp;quot;&amp;quot;&amp;quot;
        # Get system metrics
        memory_usage = psutil.virtual_memory().used / 1024 / 1024  # MB
        cpu_usage = psutil.cpu_percent(interval=1)
        
        # Get Redis metrics
        try:
            queue_size = self.redis_client.llen(f&amp;#39;{spider_name}:start_urls&amp;#39;)
            processed_count = self.redis_client.scard(f&amp;#39;{spider_name}:dupefilter&amp;#39;)
            
            # Detect queue stall
            if queue_size == self.last_queue_size:
                if not self.queue_stall_start:
                    self.queue_stall_start = time.time()
            else:
                self.queue_stall_start = None
            
            self.last_queue_size = queue_size
            
        except Exception:
            queue_size = 0
            processed_count = 0
        
        # Create metrics object (simplified - in real implementation, 
        # you&amp;#39;d collect these from your spider&amp;#39;s stats)
        metrics = ScrapingMetrics(
            timestamp=datetime.now().isoformat(),
            spider_name=spider_name,
            pages_scraped=processed_count,
            items_extracted=processed_count * 0.8,  # Estimate
            errors=processed_count * 0.05,  # Estimate
            response_time_avg=2.5,  # Would be collected from actual stats
            memory_usage_mb=memory_usage,
            cpu_usage_percent=cpu_usage,
            active_requests=queue_size,
            queue_size=queue_size,
            success_rate=0.95  # Would be calculated from actual stats
        )
        
        self.metrics_history.append(metrics)
        
        # Keep only last 24 hours of metrics
        cutoff_time = datetime.now() - timedelta(hours=24)
        self.metrics_history = [
            m for m in self.metrics_history 
            if datetime.fromisoformat(m.timestamp) &amp;gt; cutoff_time
        ]
        
        return metrics
    
    def check_alerts(self, metrics: ScrapingMetrics) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Check metrics against alert thresholds&amp;quot;&amp;quot;&amp;quot;
        alerts = []
        
        # Error rate alert
        error_rate = metrics.errors / max(1, metrics.pages_scraped)
        if error_rate &amp;gt; self.alert_thresholds[&amp;#39;error_rate&amp;#39;]:
            alerts.append({
                &amp;#39;type&amp;#39;: &amp;#39;error_rate&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;high&amp;#39;,
                &amp;#39;message&amp;#39;: f&amp;#39;High error rate: {error_rate:.2%}&amp;#39;,
                &amp;#39;value&amp;#39;: error_rate,
                &amp;#39;threshold&amp;#39;: self.alert_thresholds[&amp;#39;error_rate&amp;#39;]
            })
        
        # Memory usage alert
        if metrics.memory_usage_mb &amp;gt; self.alert_thresholds[&amp;#39;memory_usage&amp;#39;]:
            alerts.append({
                &amp;#39;type&amp;#39;: &amp;#39;memory_usage&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;medium&amp;#39;,
                &amp;#39;message&amp;#39;: f&amp;#39;High memory usage: {metrics.memory_usage_mb:.1f}MB&amp;#39;,
                &amp;#39;value&amp;#39;: metrics.memory_usage_mb,
                &amp;#39;threshold&amp;#39;: self.alert_thresholds[&amp;#39;memory_usage&amp;#39;]
            })
        
        # CPU usage alert
        if metrics.cpu_usage_percent &amp;gt; self.alert_thresholds[&amp;#39;cpu_usage&amp;#39;]:
            alerts.append({
                &amp;#39;type&amp;#39;: &amp;#39;cpu_usage&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;medium&amp;#39;,
                &amp;#39;message&amp;#39;: f&amp;#39;High CPU usage: {metrics.cpu_usage_percent:.1f}%&amp;#39;,
                &amp;#39;value&amp;#39;: metrics.cpu_usage_percent,
                &amp;#39;threshold&amp;#39;: self.alert_thresholds[&amp;#39;cpu_usage&amp;#39;]
            })
        
        # Response time alert
        if metrics.response_time_avg &amp;gt; self.alert_thresholds[&amp;#39;response_time&amp;#39;]:
            alerts.append({
                &amp;#39;type&amp;#39;: &amp;#39;response_time&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;medium&amp;#39;,
                &amp;#39;message&amp;#39;: f&amp;#39;Slow response time: {metrics.response_time_avg:.1f}s&amp;#39;,
                &amp;#39;value&amp;#39;: metrics.response_time_avg,
                &amp;#39;threshold&amp;#39;: self.alert_thresholds[&amp;#39;response_time&amp;#39;]
            })
        
        # Queue stall alert
        if self.queue_stall_start:
            stall_duration = time.time() - self.queue_stall_start
            if stall_duration &amp;gt; self.alert_thresholds[&amp;#39;queue_stall&amp;#39;]:
                alerts.append({
                    &amp;#39;type&amp;#39;: &amp;#39;queue_stall&amp;#39;,
                    &amp;#39;severity&amp;#39;: &amp;#39;high&amp;#39;,
                    &amp;#39;message&amp;#39;: f&amp;#39;Queue stalled for {stall_duration/60:.1f} minutes&amp;#39;,
                    &amp;#39;value&amp;#39;: stall_duration,
                    &amp;#39;threshold&amp;#39;: self.alert_thresholds[&amp;#39;queue_stall&amp;#39;]
                })
        
        return alerts
    
    def generate_report(self, hours: int = 24) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Generate performance report&amp;quot;&amp;quot;&amp;quot;
        cutoff_time = datetime.now() - timedelta(hours=hours)
        recent_metrics = [
            m for m in self.metrics_history 
            if datetime.fromisoformat(m.timestamp) &amp;gt; cutoff_time
        ]
        
        if not recent_metrics:
            return {&amp;#39;error&amp;#39;: &amp;#39;No metrics available&amp;#39;}
        
        # Calculate aggregated statistics
        total_pages = sum(m.pages_scraped for m in recent_metrics)
        total_items = sum(m.items_extracted for m in recent_metrics)
        total_errors = sum(m.errors for m in recent_metrics)
        avg_response_time = sum(m.response_time_avg for m in recent_metrics) / len(recent_metrics)
        avg_memory = sum(m.memory_usage_mb for m in recent_metrics) / len(recent_metrics)
        avg_cpu = sum(m.cpu_usage_percent for m in recent_metrics) / len(recent_metrics)
        
        return {
            &amp;#39;period&amp;#39;: f&amp;#39;Last {hours} hours&amp;#39;,
            &amp;#39;total_pages_scraped&amp;#39;: total_pages,
            &amp;#39;total_items_extracted&amp;#39;: total_items,
            &amp;#39;total_errors&amp;#39;: total_errors,
            &amp;#39;error_rate&amp;#39;: total_errors / max(1, total_pages),
            &amp;#39;average_response_time&amp;#39;: avg_response_time,
            &amp;#39;average_memory_usage_mb&amp;#39;: avg_memory,
            &amp;#39;average_cpu_usage_percent&amp;#39;: avg_cpu,
            &amp;#39;current_queue_size&amp;#39;: recent_metrics[-1].queue_size if recent_metrics else 0,
            &amp;#39;metrics_count&amp;#39;: len(recent_metrics)
        }

class AlertManager:
    &amp;quot;&amp;quot;&amp;quot;Manage and send alerts&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, config: Dict):
        self.config = config
        self.sent_alerts = {}  # Track sent alerts to avoid spam
        self.cooldown_period = 300  # 5 minutes cooldown between same alerts
    
    def send_alert(self, alert: Dict, metrics: ScrapingMetrics):
        &amp;quot;&amp;quot;&amp;quot;Send alert via configured channels&amp;quot;&amp;quot;&amp;quot;
        alert_key = f&amp;quot;{alert[&amp;#39;type&amp;#39;]}_{alert.get(&amp;#39;severity&amp;#39;, &amp;#39;medium&amp;#39;)}&amp;quot;
        current_time = time.time()
        
        # Check cooldown
        if alert_key in self.sent_alerts:
            if current_time - self.sent_alerts[alert_key] &amp;lt; self.cooldown_period:
                return  # Skip sending due to cooldown
        
        # Send via email
        if self.config.get(&amp;#39;email&amp;#39;):
            self._send_email_alert(alert, metrics)
        
        # Send via webhook
        if self.config.get(&amp;#39;webhook&amp;#39;):
            self._send_webhook_alert(alert, metrics)
        
        # Log to file
        self._log_alert(alert, metrics)
        
        self.sent_alerts[alert_key] = current_time
    
    def _send_email_alert(self, alert: Dict, metrics: ScrapingMetrics):
        &amp;quot;&amp;quot;&amp;quot;Send alert via email&amp;quot;&amp;quot;&amp;quot;
        try:
            smtp_config = self.config[&amp;#39;email&amp;#39;]
            
            msg = MimeMultipart()
            msg[&amp;#39;From&amp;#39;] = smtp_config[&amp;#39;from&amp;#39;]
            msg[&amp;#39;To&amp;#39;] = smtp_config[&amp;#39;to&amp;#39;]
            msg[&amp;#39;Subject&amp;#39;] = f&amp;quot;Scraping Alert: {alert[&amp;#39;type&amp;#39;]} - {alert[&amp;#39;severity&amp;#39;].upper()}&amp;quot;
            
            body = f&amp;quot;&amp;quot;&amp;quot;
            Alert: {alert[&amp;#39;message&amp;#39;]}
            
            Spider: {metrics.spider_name}
            Time: {metrics.timestamp}
            Current Value: {alert[&amp;#39;value&amp;#39;]}
            Threshold: {alert[&amp;#39;threshold&amp;#39;]}
            
            Current Metrics:
            - Pages Scraped: {metrics.pages_scraped}
            - Items Extracted: {metrics.items_extracted}
            - Error Rate: {metrics.errors / max(1, metrics.pages_scraped):.2%}
            - Memory Usage: {metrics.memory_usage_mb:.1f}MB
            - CPU Usage: {metrics.cpu_usage_percent:.1f}%
            - Queue Size: {metrics.queue_size}
            &amp;quot;&amp;quot;&amp;quot;
            
            msg.attach(MimeText(body, &amp;#39;plain&amp;#39;))
            
            with smtplib.SMTP(smtp_config[&amp;#39;smtp_server&amp;#39;], smtp_config[&amp;#39;smtp_port&amp;#39;]) as server:
                if smtp_config.get(&amp;#39;use_tls&amp;#39;):
                    server.starttls()
                if smtp_config.get(&amp;#39;username&amp;#39;):
                    server.login(smtp_config[&amp;#39;username&amp;#39;], smtp_config[&amp;#39;password&amp;#39;])
                server.send_message(msg)
                
        except Exception as e:
            print(f&amp;quot;Failed to send email alert: {e}&amp;quot;)
    
    def _send_webhook_alert(self, alert: Dict, metrics: ScrapingMetrics):
        &amp;quot;&amp;quot;&amp;quot;Send alert via webhook&amp;quot;&amp;quot;&amp;quot;
        import requests
        
        try:
            webhook_config = self.config[&amp;#39;webhook&amp;#39;]
            
            payload = {
                &amp;#39;alert&amp;#39;: alert,
                &amp;#39;metrics&amp;#39;: asdict(metrics),
                &amp;#39;timestamp&amp;#39;: metrics.timestamp
            }
            
            response = requests.post(
                webhook_config[&amp;#39;url&amp;#39;],
                json=payload,
                headers=webhook_config.get(&amp;#39;headers&amp;#39;, {}),
                timeout=10
            )
            response.raise_for_status()
            
        except Exception as e:
            print(f&amp;quot;Failed to send webhook alert: {e}&amp;quot;)
    
    def _log_alert(self, alert: Dict, metrics: ScrapingMetrics):
        &amp;quot;&amp;quot;&amp;quot;Log alert to file&amp;quot;&amp;quot;&amp;quot;
        import logging
        
        logging.basicConfig(
            filename=&amp;#39;scraping_alerts.log&amp;#39;,
            level=logging.INFO,
            format=&amp;#39;%(asctime)s - %(levelname)s - %(message)s&amp;#39;
        )
        
        logging.info(
            f&amp;quot;ALERT: {alert[&amp;#39;type&amp;#39;]} - {alert[&amp;#39;message&amp;#39;]} &amp;quot;
            f&amp;quot;(Spider: {metrics.spider_name}, Value: {alert[&amp;#39;value&amp;#39;]})&amp;quot;
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Monitoring Dashboard Script&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# scripts/monitoring_dashboard.py
import time
import json
from webscraper.monitoring import PerformanceMonitor, AlertManager

def main():
    &amp;quot;&amp;quot;&amp;quot;Main monitoring loop&amp;quot;&amp;quot;&amp;quot;
    
    # Configuration
    config = {
        &amp;#39;email&amp;#39;: {
            &amp;#39;smtp_server&amp;#39;: &amp;#39;smtp.gmail.com&amp;#39;,
            &amp;#39;smtp_port&amp;#39;: 587,
            &amp;#39;use_tls&amp;#39;: True,
            &amp;#39;username&amp;#39;: &amp;#39;your-email@gmail.com&amp;#39;,
            &amp;#39;password&amp;#39;: &amp;#39;your-app-password&amp;#39;,
            &amp;#39;from&amp;#39;: &amp;#39;your-email@gmail.com&amp;#39;,
            &amp;#39;to&amp;#39;: &amp;#39;alerts@yourcompany.com&amp;#39;
        },
        &amp;#39;webhook&amp;#39;: {
            &amp;#39;url&amp;#39;: &amp;#39;https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK&amp;#39;,
            &amp;#39;headers&amp;#39;: {&amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;}
        }
    }
    
    monitor = PerformanceMonitor()
    alert_manager = AlertManager(config)
    
    spider_name = &amp;#39;distributed_ecommerce&amp;#39;
    
    print(&amp;quot;Starting monitoring dashboard...&amp;quot;)
    print(&amp;quot;Press Ctrl+C to stop&amp;quot;)
    
    try:
        while True:
            # Collect metrics
            metrics = monitor.collect_metrics(spider_name)
            
            # Check for alerts
            alerts = monitor.check_alerts(metrics)
            
            # Send alerts
            for alert in alerts:
                alert_manager.send_alert(alert, metrics)
                print(f&amp;quot;ALERT: {alert[&amp;#39;message&amp;#39;]}&amp;quot;)
            
            # Print current status
            print(f&amp;quot;\n[{metrics.timestamp}] Spider: {spider_name}&amp;quot;)
            print(f&amp;quot;Pages: {metrics.pages_scraped}, Items: {metrics.items_extracted}&amp;quot;)
            print(f&amp;quot;Errors: {metrics.errors}, Queue: {metrics.queue_size}&amp;quot;)
            print(f&amp;quot;Memory: {metrics.memory_usage_mb:.1f}MB, CPU: {metrics.cpu_usage_percent:.1f}%&amp;quot;)
            
            if alerts:
                print(f&amp;quot;Active alerts: {len(alerts)}&amp;quot;)
            
            # Generate hourly report
            if int(time.time()) % 3600 == 0:  # Every hour
                report = monitor.generate_report(hours=1)
                print(&amp;quot;\n--- Hourly Report ---&amp;quot;)
                print(json.dumps(report, indent=2))
            
            time.sleep(60)  # Check every minute
            
    except KeyboardInterrupt:
        print(&amp;quot;\nMonitoring stopped&amp;quot;)

if __name__ == &amp;quot;__main__&amp;quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Legal Compliance and Ethics&lt;/h2&gt;
&lt;h3&gt;Compliance Framework&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/compliance.py
import time
import requests
from urllib.robotparser import RobotFileParser
from urllib.parse import urljoin, urlparse
from typing import Dict, List, Optional
import logging

class ComplianceManager:
    &amp;quot;&amp;quot;&amp;quot;Ensure legal and ethical scraping compliance&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.robots_cache = {}
        self.rate_limits = {}
        self.compliance_rules = {
            &amp;#39;respect_robots_txt&amp;#39;: True,
            &amp;#39;rate_limit_default&amp;#39;: 1.0,  # 1 second between requests
            &amp;#39;rate_limit_per_domain&amp;#39;: {},
            &amp;#39;user_agent_required&amp;#39;: True,
            &amp;#39;contact_info&amp;#39;: &amp;#39;your-email@company.com&amp;#39;,
            &amp;#39;personal_data_handling&amp;#39;: &amp;#39;exclude&amp;#39;,
            &amp;#39;copyright_respect&amp;#39;: True,
        }
        
        self.logger = logging.getLogger(__name__)
    
    def check_robots_txt(self, url: str, user_agent: str = &amp;#39;*&amp;#39;) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Check if URL is allowed by robots.txt&amp;quot;&amp;quot;&amp;quot;
        domain = urlparse(url).netloc
        
        if domain not in self.robots_cache:
            robots_url = urljoin(f&amp;quot;https://{domain}&amp;quot;, &amp;quot;/robots.txt&amp;quot;)
            
            try:
                rp = RobotFileParser()
                rp.set_url(robots_url)
                rp.read()
                self.robots_cache[domain] = rp
                
                # Extract crawl delay if specified
                delay = rp.crawl_delay(user_agent)
                if delay:
                    self.rate_limits[domain] = float(delay)
                
            except Exception as e:
                self.logger.warning(f&amp;quot;Could not fetch robots.txt for {domain}: {e}&amp;quot;)
                return True  # Allow if robots.txt can&amp;#39;t be fetched
        
        robots_parser = self.robots_cache.get(domain)
        if robots_parser:
            return robots_parser.can_fetch(user_agent, url)
        
        return True
    
    def get_rate_limit(self, domain: str) -&amp;gt; float:
        &amp;quot;&amp;quot;&amp;quot;Get appropriate rate limit for domain&amp;quot;&amp;quot;&amp;quot;
        # Check domain-specific rate limit
        if domain in self.compliance_rules[&amp;#39;rate_limit_per_domain&amp;#39;]:
            return self.compliance_rules[&amp;#39;rate_limit_per_domain&amp;#39;][domain]
        
        # Check robots.txt specified delay
        if domain in self.rate_limits:
            return self.rate_limits[domain]
        
        # Use default
        return self.compliance_rules[&amp;#39;rate_limit_default&amp;#39;]
    
    def validate_request(self, request) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Validate request against compliance rules&amp;quot;&amp;quot;&amp;quot;
        url = request.url
        domain = urlparse(url).netloc
        user_agent = request.headers.get(&amp;#39;User-Agent&amp;#39;, &amp;#39;&amp;#39;)
        
        # Check robots.txt
        if self.compliance_rules[&amp;#39;respect_robots_txt&amp;#39;]:
            if not self.check_robots_txt(url, user_agent):
                self.logger.warning(f&amp;quot;Blocked by robots.txt: {url}&amp;quot;)
                return False
        
        # Check user agent requirement
        if self.compliance_rules[&amp;#39;user_agent_required&amp;#39;]:
            if not user_agent or &amp;#39;bot&amp;#39; not in user_agent.lower():
                self.logger.warning(f&amp;quot;Invalid user agent for compliance: {user_agent}&amp;quot;)
                return False
        
        return True
    
    def filter_personal_data(self, item: Dict) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Filter out personal data from scraped items&amp;quot;&amp;quot;&amp;quot;
        if self.compliance_rules[&amp;#39;personal_data_handling&amp;#39;] == &amp;#39;exclude&amp;#39;:
            sensitive_fields = [
                &amp;#39;email&amp;#39;, &amp;#39;phone&amp;#39;, &amp;#39;address&amp;#39;, &amp;#39;ssn&amp;#39;, &amp;#39;credit_card&amp;#39;,
                &amp;#39;personal_id&amp;#39;, &amp;#39;passport&amp;#39;, &amp;#39;driver_license&amp;#39;
            ]
            
            filtered_item = {}
            for key, value in item.items():
                if key.lower() not in sensitive_fields:
                    # Additional check for values that look like personal data
                    if not self.looks_like_personal_data(str(value)):
                        filtered_item[key] = value
                    else:
                        self.logger.info(f&amp;quot;Filtered potential personal data: {key}&amp;quot;)
            
            return filtered_item
        
        return item
    
    def looks_like_personal_data(self, text: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Heuristic check for personal data patterns&amp;quot;&amp;quot;&amp;quot;
        import re
        
        patterns = [
            r&amp;#39;\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b&amp;#39;,  # Email
            r&amp;#39;\b\d{3}-\d{2}-\d{4}\b&amp;#39;,  # SSN
            r&amp;#39;\b\d{3}-\d{3}-\d{4}\b&amp;#39;,  # Phone
            r&amp;#39;\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b&amp;#39;,  # Credit card
        ]
        
        for pattern in patterns:
            if re.search(pattern, text):
                return True
        
        return False
    
    def generate_compliance_report(self) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Generate compliance status report&amp;quot;&amp;quot;&amp;quot;
        return {
            &amp;#39;robots_txt_domains_checked&amp;#39;: len(self.robots_cache),
            &amp;#39;custom_rate_limits&amp;#39;: len(self.rate_limits),
            &amp;#39;compliance_rules&amp;#39;: self.compliance_rules,
            &amp;#39;timestamp&amp;#39;: time.time()
        }

# Compliance middleware
class ComplianceMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Scrapy middleware for compliance enforcement&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.compliance_manager = ComplianceManager()
        self.last_request_time = {}
    
    def process_request(self, request, spider):
        # Validate compliance
        if not self.compliance_manager.validate_request(request):
            raise IgnoreRequest(f&amp;quot;Request blocked by compliance rules: {request.url}&amp;quot;)
        
        # Enforce rate limiting
        domain = urlparse(request.url).netloc
        rate_limit = self.compliance_manager.get_rate_limit(domain)
        
        if domain in self.last_request_time:
            time_since_last = time.time() - self.last_request_time[domain]
            if time_since_last &amp;lt; rate_limit:
                sleep_time = rate_limit - time_since_last
                spider.logger.debug(f&amp;quot;Rate limiting: sleeping {sleep_time:.2f}s for {domain}&amp;quot;)
                time.sleep(sleep_time)
        
        self.last_request_time[domain] = time.time()
    
    def process_item(self, item, spider):
        # Filter personal data
        filtered_item = self.compliance_manager.filter_personal_data(dict(item))
        return filtered_item
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;This part covered the operational side: stealth middleware and fingerprint consistency, proxy pools with health checks, distributed workers on Scrapy-Redis, monitoring with alerting, and a compliance layer that enforces robots.txt and rate limits.&lt;/p&gt;
&lt;h3&gt;What&amp;#39;s Next?&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;/tutorials/web-scraping-scrapy-part-4&quot;&gt;Part 4: Data Processing and Storage&lt;/a&gt; covers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Advanced data cleaning and validation&lt;/li&gt;
&lt;li&gt;Multiple storage backends (MongoDB, PostgreSQL, Elasticsearch)&lt;/li&gt;
&lt;li&gt;Real-time data pipelines&lt;/li&gt;
&lt;li&gt;Data quality monitoring&lt;/li&gt;
&lt;li&gt;ETL processes and data warehousing&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Practice Exercise&lt;/h3&gt;
&lt;p&gt;Build a distributed scraper that:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Uses anti-detection techniques to scrape a major e-commerce site&lt;/li&gt;
&lt;li&gt;Scales across multiple workers with Redis coordination&lt;/li&gt;
&lt;li&gt;Implements monitoring and alerting&lt;/li&gt;
&lt;li&gt;Ensures legal compliance and ethical scraping&lt;/li&gt;
&lt;li&gt;Handles millions of products with proper rate limiting&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>Scrapy, part 2: JavaScript, forms, and dynamic pages</title><link>https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-2/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-2/</guid><description>Rendering JavaScript, submitting forms, and chasing the AJAX endpoints that actually hold the data.</description><pubDate>Sun, 22 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;JavaScript, forms, and dynamic pages&lt;/h1&gt;
&lt;p&gt;Part 1 covered static HTML. This part deals with sites that don&amp;#39;t hand you clean markup: rendering JavaScript with Splash, submitting forms and holding sessions, and pulling data straight from the AJAX endpoints that actually hold it.&lt;/p&gt;
&lt;h2&gt;What this part covers&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;JavaScript rendering with Scrapy-Splash integration&lt;/li&gt;
&lt;li&gt;Handling forms, logins, and session management&lt;/li&gt;
&lt;li&gt;Extracting data from AJAX requests and APIs&lt;/li&gt;
&lt;li&gt;Advanced selector techniques and data extraction&lt;/li&gt;
&lt;li&gt;Custom middleware development&lt;/li&gt;
&lt;li&gt;Handling cookies, headers, and authentication&lt;/li&gt;
&lt;li&gt;Working with infinite scroll and pagination&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;JavaScript-Heavy Websites with Scrapy-Splash&lt;/h2&gt;
&lt;p&gt;Scrapy alone cannot execute JavaScript. For pages that render their content client-side, Scrapy-Splash fills the gap.&lt;/p&gt;
&lt;h3&gt;Setting Up Splash&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install Docker (required for Splash)
# On macOS with Homebrew:
brew install docker

# Start Docker service and run Splash
docker run -p 8050:8050 scrapinghub/splash

# Install Scrapy-Splash
pip install scrapy-splash
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Configuring Scrapy for Splash&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/settings.py

# Splash settings
SPLASH_URL = &amp;#39;http://localhost:8050&amp;#39;

# Enable Splash middleware
DOWNLOADER_MIDDLEWARES = {
    &amp;#39;scrapy_splash.SplashCookiesMiddleware&amp;#39;: 723,
    &amp;#39;scrapy_splash.SplashMiddleware&amp;#39;: 725,
    &amp;#39;scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware&amp;#39;: 810,
}

# Enable Splash spider middleware
SPIDER_MIDDLEWARES = {
    &amp;#39;scrapy_splash.SplashDeduplicateArgsMiddleware&amp;#39;: 100,
}

# Splash duplicate filter
DUPEFILTER_CLASS = &amp;#39;scrapy_splash.SplashAwareDupeFilter&amp;#39;

# Splash HTTP cache storage backend
HTTPCACHE_STORAGE = &amp;#39;scrapy_splash.SplashAwareFSCacheStorage&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;JavaScript-Enabled Spider&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/spiders/spa_spider.py
import scrapy
from scrapy_splash import SplashRequest
from webscraper.items import ProductItem
from scrapy.loader import ItemLoader
import json

class SPASpider(scrapy.Spider):
    name = &amp;#39;spa_scraper&amp;#39;
    allowed_domains = [&amp;#39;example-spa.com&amp;#39;]
    
    custom_settings = {
        &amp;#39;DOWNLOAD_DELAY&amp;#39;: 2,
        &amp;#39;SPLASH_URL&amp;#39;: &amp;#39;http://localhost:8050&amp;#39;,
    }
    
    def start_requests(self):
        urls = [&amp;#39;https://example-spa.com/products&amp;#39;]
        
        # Lua script for complex interactions
        lua_script = &amp;quot;&amp;quot;&amp;quot;
        function main(splash, args)
            splash.private_mode_enabled = false
            splash:go(args.url)
            splash:wait(3)
            
            -- Wait for products to load
            splash:wait_for_resume([[
                function main(splash) {
                    var products = document.querySelectorAll(&amp;#39;.product-item&amp;#39;);
                    if (products.length &amp;gt; 0) {
                        splash.resume(&amp;#39;Products loaded&amp;#39;);
                    } else {
                        setTimeout(function() {
                            splash.resume(&amp;#39;Timeout&amp;#39;);
                        }, 10000);
                    }
                }
            ]], 15)
            
            -- Scroll to load more content
            splash:runjs([[
                window.scrollTo(0, document.body.scrollHeight);
            ]])
            splash:wait(2)
            
            -- Click &amp;quot;Load More&amp;quot; button if present
            local load_more = splash:select(&amp;#39;.load-more-btn&amp;#39;)
            if load_more then
                load_more:click()
                splash:wait(3)
            end
            
            return {
                html = splash:html(),
                png = splash:png(),
                har = splash:har(),
                url = splash:url()
            }
        end
        &amp;quot;&amp;quot;&amp;quot;
        
        for url in urls:
            yield SplashRequest(
                url=url,
                callback=self.parse,
                args={
                    &amp;#39;lua_source&amp;#39;: lua_script,
                    &amp;#39;timeout&amp;#39;: 30,
                    &amp;#39;resource_timeout&amp;#39;: 10,
                    &amp;#39;wait&amp;#39;: 5,
                }
            )
    
    def parse(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse SPA product listings&amp;quot;&amp;quot;&amp;quot;
        self.logger.info(f&amp;#39;Parsing SPA page: {response.url}&amp;#39;)
        
        # Extract products from JavaScript-rendered content
        products = response.css(&amp;#39;.product-item&amp;#39;)
        
        for product in products:
            product_url = product.css(&amp;#39;a::attr(href)&amp;#39;).get()
            if product_url:
                # Use SplashRequest for product pages too
                yield SplashRequest(
                    url=response.urljoin(product_url),
                    callback=self.parse_product,
                    args={&amp;#39;wait&amp;#39;: 3}
                )
        
        # Handle pagination in SPA
        next_page_data = response.css(&amp;#39;script[type=&amp;quot;application/json&amp;quot;]::text&amp;#39;).get()
        if next_page_data:
            try:
                data = json.loads(next_page_data)
                if data.get(&amp;#39;nextPage&amp;#39;):
                    yield SplashRequest(
                        url=data[&amp;#39;nextPage&amp;#39;],
                        callback=self.parse,
                        args={&amp;#39;wait&amp;#39;: 3}
                    )
            except json.JSONDecodeError:
                pass
    
    def parse_product(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse individual product from SPA&amp;quot;&amp;quot;&amp;quot;
        loader = ItemLoader(item=ProductItem(), response=response)
        
        # Extract data that might be loaded via JavaScript
        loader.add_css(&amp;#39;name&amp;#39;, &amp;#39;h1.product-title::text&amp;#39;)
        loader.add_css(&amp;#39;price&amp;#39;, &amp;#39;.price-display::text&amp;#39;)
        loader.add_css(&amp;#39;description&amp;#39;, &amp;#39;.product-description::text&amp;#39;)
        
        # Extract from JavaScript variables
        js_data = self.extract_js_data(response)
        if js_data:
            loader.add_value(&amp;#39;name&amp;#39;, js_data.get(&amp;#39;productName&amp;#39;))
            loader.add_value(&amp;#39;price&amp;#39;, js_data.get(&amp;#39;price&amp;#39;))
            loader.add_value(&amp;#39;sku&amp;#39;, js_data.get(&amp;#39;sku&amp;#39;))
        
        loader.add_value(&amp;#39;url&amp;#39;, response.url)
        loader.add_value(&amp;#39;source&amp;#39;, &amp;#39;spa&amp;#39;)
        
        yield loader.load_item()
    
    def extract_js_data(self, response):
        &amp;quot;&amp;quot;&amp;quot;Extract data from JavaScript variables&amp;quot;&amp;quot;&amp;quot;
        # Look for common patterns
        js_patterns = [
            r&amp;#39;window\.productData\s*=\s*({[^}]+})&amp;#39;,
            r&amp;#39;var\s+product\s*=\s*({[^}]+})&amp;#39;,
            r&amp;#39;__INITIAL_STATE__\s*=\s*({.+?});&amp;#39;
        ]
        
        for pattern in js_patterns:
            import re
            match = re.search(pattern, response.text)
            if match:
                try:
                    return json.loads(match.group(1))
                except json.JSONDecodeError:
                    continue
        
        return None
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Form Handling and Authentication&lt;/h2&gt;
&lt;p&gt;Many sites put the useful data behind a login or a search form. Here&amp;#39;s how to handle both:&lt;/p&gt;
&lt;h3&gt;Login Spider&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/spiders/login_spider.py
import scrapy
from scrapy import FormRequest
from webscraper.items import ProductItem

class LoginSpider(scrapy.Spider):
    name = &amp;#39;login_scraper&amp;#39;
    allowed_domains = [&amp;#39;secure-store.com&amp;#39;]
    start_urls = [&amp;#39;https://secure-store.com/login&amp;#39;]
    
    def parse(self, response):
        &amp;quot;&amp;quot;&amp;quot;Handle login form&amp;quot;&amp;quot;&amp;quot;
        # Check if already logged in
        if self.is_logged_in(response):
            return self.after_login(response)
        
        # Extract form data and CSRF tokens
        csrf_token = response.css(&amp;#39;input[name=&amp;quot;csrf_token&amp;quot;]::attr(value)&amp;#39;).get()
        
        # Submit login form
        return FormRequest.from_response(
            response,
            formdata={
                &amp;#39;username&amp;#39;: &amp;#39;your_username&amp;#39;,
                &amp;#39;password&amp;#39;: &amp;#39;your_password&amp;#39;,
                &amp;#39;csrf_token&amp;#39;: csrf_token,
                &amp;#39;remember_me&amp;#39;: &amp;#39;1&amp;#39;
            },
            callback=self.after_login,
            dont_filter=True
        )
    
    def is_logged_in(self, response):
        &amp;quot;&amp;quot;&amp;quot;Check if successfully logged in&amp;quot;&amp;quot;&amp;quot;
        return bool(response.css(&amp;#39;.user-dashboard&amp;#39;))
    
    def after_login(self, response):
        &amp;quot;&amp;quot;&amp;quot;Handle post-login logic&amp;quot;&amp;quot;&amp;quot;
        if not self.is_logged_in(response):
            self.logger.error(&amp;#39;Login failed&amp;#39;)
            return
        
        self.logger.info(&amp;#39;Successfully logged in&amp;#39;)
        
        # Navigate to protected areas
        protected_urls = [
            &amp;#39;https://secure-store.com/members/products&amp;#39;,
            &amp;#39;https://secure-store.com/premium/catalog&amp;#39;
        ]
        
        for url in protected_urls:
            yield response.follow(url, callback=self.parse_protected_content)
    
    def parse_protected_content(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse content that requires authentication&amp;quot;&amp;quot;&amp;quot;
        products = response.css(&amp;#39;.premium-product&amp;#39;)
        
        for product in products:
            loader = ItemLoader(item=ProductItem(), selector=product)
            loader.add_css(&amp;#39;name&amp;#39;, &amp;#39;.product-name::text&amp;#39;)
            loader.add_css(&amp;#39;price&amp;#39;, &amp;#39;.member-price::text&amp;#39;)
            loader.add_css(&amp;#39;description&amp;#39;, &amp;#39;.product-desc::text&amp;#39;)
            loader.add_value(&amp;#39;source&amp;#39;, &amp;#39;premium&amp;#39;)
            
            yield loader.load_item()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Complex Form Handling&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/spiders/form_spider.py
import scrapy
from scrapy import FormRequest
import json

class FormSpider(scrapy.Spider):
    name = &amp;#39;form_handler&amp;#39;
    
    def start_requests(self):
        # Start with a search form
        yield scrapy.Request(
            &amp;#39;https://example.com/search&amp;#39;,
            callback=self.parse_search_form
        )
    
    def parse_search_form(self, response):
        &amp;quot;&amp;quot;&amp;quot;Handle complex search forms&amp;quot;&amp;quot;&amp;quot;
        # Extract all form fields and hidden values
        form_data = {}
        
        # Get all input fields
        for input_field in response.css(&amp;#39;form input&amp;#39;):
            name = input_field.css(&amp;#39;::attr(name)&amp;#39;).get()
            value = input_field.css(&amp;#39;::attr(value)&amp;#39;).get()
            input_type = input_field.css(&amp;#39;::attr(type)&amp;#39;).get()
            
            if name:
                if input_type == &amp;#39;checkbox&amp;#39; and not input_field.css(&amp;#39;::attr(checked)&amp;#39;).get():
                    continue  # Skip unchecked checkboxes
                form_data[name] = value or &amp;#39;&amp;#39;
        
        # Get select fields
        for select in response.css(&amp;#39;form select&amp;#39;):
            name = select.css(&amp;#39;::attr(name)&amp;#39;).get()
            selected = select.css(&amp;#39;option[selected]::attr(value)&amp;#39;).get()
            if name:
                form_data[name] = selected or &amp;#39;&amp;#39;
        
        # Add our search parameters
        search_params = {
            &amp;#39;query&amp;#39;: &amp;#39;laptops&amp;#39;,
            &amp;#39;category&amp;#39;: &amp;#39;electronics&amp;#39;,
            &amp;#39;price_min&amp;#39;: &amp;#39;500&amp;#39;,
            &amp;#39;price_max&amp;#39;: &amp;#39;2000&amp;#39;,
            &amp;#39;sort&amp;#39;: &amp;#39;price_desc&amp;#39;
        }
        form_data.update(search_params)
        
        # Submit form
        yield FormRequest.from_response(
            response,
            formdata=form_data,
            callback=self.parse_search_results
        )
    
    def parse_search_results(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse search results&amp;quot;&amp;quot;&amp;quot;
        products = response.css(&amp;#39;.search-result-item&amp;#39;)
        
        for product in products:
            # Extract product details
            product_data = {
                &amp;#39;name&amp;#39;: product.css(&amp;#39;.product-title::text&amp;#39;).get(),
                &amp;#39;price&amp;#39;: product.css(&amp;#39;.price::text&amp;#39;).get(),
                &amp;#39;rating&amp;#39;: product.css(&amp;#39;.rating::attr(data-rating)&amp;#39;).get(),
                &amp;#39;url&amp;#39;: response.urljoin(product.css(&amp;#39;a::attr(href)&amp;#39;).get())
            }
            
            if product_data[&amp;#39;url&amp;#39;]:
                yield response.follow(
                    product_data[&amp;#39;url&amp;#39;],
                    callback=self.parse_product,
                    meta={&amp;#39;product_data&amp;#39;: product_data}
                )
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;AJAX Requests and API Integration&lt;/h2&gt;
&lt;p&gt;When a site loads its data via AJAX, hitting the API directly is usually faster and more reliable than rendering the page. Here&amp;#39;s how to find and work with those requests:&lt;/p&gt;
&lt;h3&gt;AJAX Spider&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/spiders/ajax_spider.py
import scrapy
import json
from urllib.parse import urlencode

class AjaxSpider(scrapy.Spider):
    name = &amp;#39;ajax_scraper&amp;#39;
    allowed_domains = [&amp;#39;api-example.com&amp;#39;]
    
    def start_requests(self):
        # Start with the main page to get initial data
        yield scrapy.Request(
            &amp;#39;https://api-example.com/products&amp;#39;,
            callback=self.parse_initial_page
        )
    
    def parse_initial_page(self, response):
        &amp;quot;&amp;quot;&amp;quot;Extract API endpoints and initial data&amp;quot;&amp;quot;&amp;quot;
        # Look for API endpoints in JavaScript
        api_endpoints = self.extract_api_endpoints(response)
        
        # Extract pagination info
        total_pages = response.css(&amp;#39;.pagination::attr(data-total-pages)&amp;#39;).get()
        if total_pages:
            total_pages = int(total_pages)
        else:
            total_pages = 10  # Default fallback
        
        # Generate API requests for all pages
        for page in range(1, total_pages + 1):
            api_url = f&amp;#39;https://api-example.com/api/products&amp;#39;
            params = {
                &amp;#39;page&amp;#39;: page,
                &amp;#39;limit&amp;#39;: 20,
                &amp;#39;format&amp;#39;: &amp;#39;json&amp;#39;
            }
            
            url = f&amp;quot;{api_url}?{urlencode(params)}&amp;quot;
            yield scrapy.Request(
                url=url,
                callback=self.parse_api_response,
                headers={
                    &amp;#39;Accept&amp;#39;: &amp;#39;application/json&amp;#39;,
                    &amp;#39;X-Requested-With&amp;#39;: &amp;#39;XMLHttpRequest&amp;#39;,
                    &amp;#39;Referer&amp;#39;: response.url
                }
            )
    
    def extract_api_endpoints(self, response):
        &amp;quot;&amp;quot;&amp;quot;Extract API endpoints from JavaScript&amp;quot;&amp;quot;&amp;quot;
        endpoints = []
        
        # Common patterns for API endpoints
        import re
        patterns = [
            r&amp;#39;api[\&amp;#39;&amp;quot;]:\s*[\&amp;#39;&amp;quot;]([^\&amp;#39;\&amp;quot;]+)&amp;#39;,
            r&amp;#39;endpoint[\&amp;#39;&amp;quot;]:\s*[\&amp;#39;&amp;quot;]([^\&amp;#39;\&amp;quot;]+)&amp;#39;,
            r&amp;#39;fetch\([\&amp;#39;&amp;quot;]([^\&amp;#39;\&amp;quot;]+)&amp;#39;,
            r&amp;#39;axios\.get\([\&amp;#39;&amp;quot;]([^\&amp;#39;\&amp;quot;]+)&amp;#39;
        ]
        
        for pattern in patterns:
            matches = re.findall(pattern, response.text)
            endpoints.extend(matches)
        
        return list(set(endpoints))  # Remove duplicates
    
    def parse_api_response(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse JSON API responses&amp;quot;&amp;quot;&amp;quot;
        try:
            data = json.loads(response.text)
        except json.JSONDecodeError:
            self.logger.error(f&amp;#39;Invalid JSON response from {response.url}&amp;#39;)
            return
        
        # Handle different API response structures
        products = data.get(&amp;#39;products&amp;#39;, data.get(&amp;#39;data&amp;#39;, data.get(&amp;#39;items&amp;#39;, [])))
        
        for product in products:
            # Create item from API data
            product_item = {
                &amp;#39;id&amp;#39;: product.get(&amp;#39;id&amp;#39;),
                &amp;#39;name&amp;#39;: product.get(&amp;#39;name&amp;#39;, product.get(&amp;#39;title&amp;#39;)),
                &amp;#39;price&amp;#39;: product.get(&amp;#39;price&amp;#39;),
                &amp;#39;description&amp;#39;: product.get(&amp;#39;description&amp;#39;),
                &amp;#39;category&amp;#39;: product.get(&amp;#39;category&amp;#39;),
                &amp;#39;images&amp;#39;: product.get(&amp;#39;images&amp;#39;, []),
                &amp;#39;url&amp;#39;: product.get(&amp;#39;url&amp;#39;),
                &amp;#39;api_source&amp;#39;: response.url
            }
            
            yield product_item
        
        # Handle API pagination
        pagination = data.get(&amp;#39;pagination&amp;#39;, {})
        if pagination.get(&amp;#39;hasNextPage&amp;#39;):
            next_page = pagination.get(&amp;#39;nextPage&amp;#39;)
            if next_page:
                yield response.follow(
                    next_page,
                    callback=self.parse_api_response
                )
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Real-time Data Spider&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/spiders/realtime_spider.py
import scrapy
import json
import time
from datetime import datetime

class RealtimeSpider(scrapy.Spider):
    name = &amp;#39;realtime_scraper&amp;#39;
    
    custom_settings = {
        &amp;#39;DOWNLOAD_DELAY&amp;#39;: 5,  # Respectful delay for real-time data
        &amp;#39;CONCURRENT_REQUESTS&amp;#39;: 1,  # Sequential requests for real-time
    }
    
    def start_requests(self):
        # Monitor real-time endpoints
        endpoints = [
            &amp;#39;https://api.example.com/live/stock-prices&amp;#39;,
            &amp;#39;https://api.example.com/live/crypto-prices&amp;#39;,
            &amp;#39;https://api.example.com/live/forex-rates&amp;#39;
        ]
        
        for endpoint in endpoints:
            yield scrapy.Request(
                endpoint,
                callback=self.parse_realtime_data,
                meta={
                    &amp;#39;endpoint_type&amp;#39;: self.get_endpoint_type(endpoint),
                    &amp;#39;start_time&amp;#39;: time.time()
                }
            )
    
    def get_endpoint_type(self, endpoint):
        &amp;quot;&amp;quot;&amp;quot;Determine endpoint type from URL&amp;quot;&amp;quot;&amp;quot;
        if &amp;#39;stock&amp;#39; in endpoint:
            return &amp;#39;stocks&amp;#39;
        elif &amp;#39;crypto&amp;#39; in endpoint:
            return &amp;#39;cryptocurrency&amp;#39;
        elif &amp;#39;forex&amp;#39; in endpoint:
            return &amp;#39;forex&amp;#39;
        return &amp;#39;unknown&amp;#39;
    
    def parse_realtime_data(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse real-time financial data&amp;quot;&amp;quot;&amp;quot;
        try:
            data = json.loads(response.text)
        except json.JSONDecodeError:
            return
        
        endpoint_type = response.meta[&amp;#39;endpoint_type&amp;#39;]
        timestamp = datetime.now().isoformat()
        
        # Process based on endpoint type
        if endpoint_type == &amp;#39;stocks&amp;#39;:
            yield from self.process_stock_data(data, timestamp)
        elif endpoint_type == &amp;#39;cryptocurrency&amp;#39;:
            yield from self.process_crypto_data(data, timestamp)
        elif endpoint_type == &amp;#39;forex&amp;#39;:
            yield from self.process_forex_data(data, timestamp)
        
        # Schedule next request for continuous monitoring
        yield scrapy.Request(
            response.url,
            callback=self.parse_realtime_data,
            meta=response.meta,
            dont_filter=True  # Allow duplicate requests
        )
    
    def process_stock_data(self, data, timestamp):
        &amp;quot;&amp;quot;&amp;quot;Process stock price data&amp;quot;&amp;quot;&amp;quot;
        stocks = data.get(&amp;#39;stocks&amp;#39;, [])
        
        for stock in stocks:
            yield {
                &amp;#39;type&amp;#39;: &amp;#39;stock&amp;#39;,
                &amp;#39;symbol&amp;#39;: stock.get(&amp;#39;symbol&amp;#39;),
                &amp;#39;price&amp;#39;: stock.get(&amp;#39;price&amp;#39;),
                &amp;#39;change&amp;#39;: stock.get(&amp;#39;change&amp;#39;),
                &amp;#39;change_percent&amp;#39;: stock.get(&amp;#39;changePercent&amp;#39;),
                &amp;#39;volume&amp;#39;: stock.get(&amp;#39;volume&amp;#39;),
                &amp;#39;timestamp&amp;#39;: timestamp,
                &amp;#39;market_cap&amp;#39;: stock.get(&amp;#39;marketCap&amp;#39;)
            }
    
    def process_crypto_data(self, data, timestamp):
        &amp;quot;&amp;quot;&amp;quot;Process cryptocurrency data&amp;quot;&amp;quot;&amp;quot;
        currencies = data.get(&amp;#39;data&amp;#39;, [])
        
        for currency in currencies:
            yield {
                &amp;#39;type&amp;#39;: &amp;#39;cryptocurrency&amp;#39;,
                &amp;#39;symbol&amp;#39;: currency.get(&amp;#39;symbol&amp;#39;),
                &amp;#39;name&amp;#39;: currency.get(&amp;#39;name&amp;#39;),
                &amp;#39;price_usd&amp;#39;: currency.get(&amp;#39;price_usd&amp;#39;),
                &amp;#39;price_btc&amp;#39;: currency.get(&amp;#39;price_btc&amp;#39;),
                &amp;#39;volume_24h&amp;#39;: currency.get(&amp;#39;24h_volume_usd&amp;#39;),
                &amp;#39;market_cap&amp;#39;: currency.get(&amp;#39;market_cap_usd&amp;#39;),
                &amp;#39;change_24h&amp;#39;: currency.get(&amp;#39;percent_change_24h&amp;#39;),
                &amp;#39;timestamp&amp;#39;: timestamp
            }
    
    def process_forex_data(self, data, timestamp):
        &amp;quot;&amp;quot;&amp;quot;Process forex rates data&amp;quot;&amp;quot;&amp;quot;
        rates = data.get(&amp;#39;rates&amp;#39;, {})
        base_currency = data.get(&amp;#39;base&amp;#39;, &amp;#39;USD&amp;#39;)
        
        for currency, rate in rates.items():
            yield {
                &amp;#39;type&amp;#39;: &amp;#39;forex&amp;#39;,
                &amp;#39;base_currency&amp;#39;: base_currency,
                &amp;#39;target_currency&amp;#39;: currency,
                &amp;#39;rate&amp;#39;: rate,
                &amp;#39;timestamp&amp;#39;: timestamp
            }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Advanced Selector Techniques&lt;/h2&gt;
&lt;h3&gt;Complex XPath and CSS Selectors&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Advanced selector utilities
class AdvancedSelectors:
    
    @staticmethod
    def extract_with_fallbacks(response, selectors):
        &amp;quot;&amp;quot;&amp;quot;Try multiple selectors until one works&amp;quot;&amp;quot;&amp;quot;
        for selector in selectors:
            if selector.startswith(&amp;#39;//&amp;#39;):
                # XPath selector
                result = response.xpath(selector).get()
            else:
                # CSS selector
                result = response.css(selector).get()
            
            if result:
                return result.strip()
        return None
    
    @staticmethod
    def extract_text_near_element(response, anchor_text, search_area=&amp;#39;following&amp;#39;):
        &amp;quot;&amp;quot;&amp;quot;Extract text near a specific element&amp;quot;&amp;quot;&amp;quot;
        if search_area == &amp;#39;following&amp;#39;:
            xpath = f&amp;quot;//text()[contains(., &amp;#39;{anchor_text}&amp;#39;)]/following::text()[1]&amp;quot;
        elif search_area == &amp;#39;preceding&amp;#39;:
            xpath = f&amp;quot;//text()[contains(., &amp;#39;{anchor_text}&amp;#39;)]/preceding::text()[1]&amp;quot;
        elif search_area == &amp;#39;parent&amp;#39;:
            xpath = f&amp;quot;//text()[contains(., &amp;#39;{anchor_text}&amp;#39;)]/parent::*/text()&amp;quot;
        
        return response.xpath(xpath).get()
    
    @staticmethod
    def extract_table_data(response, table_selector):
        &amp;quot;&amp;quot;&amp;quot;Extract structured data from tables&amp;quot;&amp;quot;&amp;quot;
        table = response.css(table_selector)
        if not table:
            return []
        
        headers = table.css(&amp;#39;thead tr th::text&amp;#39;).getall()
        if not headers:
            headers = table.css(&amp;#39;tr:first-child td::text&amp;#39;).getall()
        
        rows = []
        for row in table.css(&amp;#39;tbody tr, tr&amp;#39;)[1:]:  # Skip header row
            cells = row.css(&amp;#39;td::text&amp;#39;).getall()
            if len(cells) == len(headers):
                row_data = dict(zip(headers, cells))
                rows.append(row_data)
        
        return rows
    
    @staticmethod
    def extract_nested_json(response, script_selector):
        &amp;quot;&amp;quot;&amp;quot;Extract JSON data from script tags&amp;quot;&amp;quot;&amp;quot;
        scripts = response.css(script_selector)
        
        for script in scripts:
            content = script.get()
            # Try to find JSON objects
            import re
            import json
            
            json_patterns = [
                r&amp;#39;var\s+\w+\s*=\s*({.+?});&amp;#39;,
                r&amp;#39;window\.\w+\s*=\s*({.+?});&amp;#39;,
                r&amp;#39;data:\s*({.+?})&amp;#39;,
            ]
            
            for pattern in json_patterns:
                matches = re.findall(pattern, content, re.DOTALL)
                for match in matches:
                    try:
                        return json.loads(match)
                    except json.JSONDecodeError:
                        continue
        
        return None

# Usage in spider
def parse_complex_page(self, response):
    &amp;quot;&amp;quot;&amp;quot;Example using advanced selectors&amp;quot;&amp;quot;&amp;quot;
    selectors = AdvancedSelectors()
    
    # Try multiple price selectors
    price = selectors.extract_with_fallbacks(response, [
        &amp;#39;.price-current::text&amp;#39;,
        &amp;#39;.price::text&amp;#39;,
        &amp;#39;//span[@class=&amp;quot;price&amp;quot;]//text()&amp;#39;,
        &amp;#39;.product-price .value::text&amp;#39;
    ])
    
    # Extract text near &amp;quot;Price:&amp;quot; label
    price_alt = selectors.extract_text_near_element(response, &amp;#39;Price:&amp;#39;, &amp;#39;following&amp;#39;)
    
    # Extract table data
    specs = selectors.extract_table_data(response, &amp;#39;.specifications-table&amp;#39;)
    
    # Extract JSON configuration
    config = selectors.extract_nested_json(response, &amp;#39;script[type=&amp;quot;application/json&amp;quot;]&amp;#39;)
    
    yield {
        &amp;#39;price&amp;#39;: price or price_alt,
        &amp;#39;specifications&amp;#39;: specs,
        &amp;#39;config&amp;#39;: config
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Custom Middleware Development&lt;/h2&gt;
&lt;h3&gt;Rotation Middleware&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/middlewares.py
import random
import time
from scrapy.downloadermiddlewares.useragent import UserAgentMiddleware
from scrapy.exceptions import NotConfigured

class RotatingUserAgentMiddleware(UserAgentMiddleware):
    &amp;quot;&amp;quot;&amp;quot;Rotate user agents to avoid detection&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, user_agent=&amp;#39;&amp;#39;):
        self.user_agent = user_agent
        
        # List of realistic user agents
        self.user_agent_list = [
            &amp;#39;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&amp;#39;,
            &amp;#39;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&amp;#39;,
            &amp;#39;Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0&amp;#39;,
            &amp;#39;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15&amp;#39;,
            &amp;#39;Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&amp;#39;,
        ]
    
    def process_request(self, request, spider):
        ua = random.choice(self.user_agent_list)
        request.headers[&amp;#39;User-Agent&amp;#39;] = ua
        return None

class ProxyRotationMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Rotate proxies to distribute requests&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, proxy_list=None):
        if not proxy_list:
            raise NotConfigured(&amp;#39;No proxy list provided&amp;#39;)
        
        self.proxy_list = proxy_list
        self.proxy_index = 0
    
    @classmethod
    def from_crawler(cls, crawler):
        proxy_list = crawler.settings.getlist(&amp;#39;PROXY_LIST&amp;#39;)
        return cls(proxy_list)
    
    def process_request(self, request, spider):
        proxy = self.proxy_list[self.proxy_index]
        self.proxy_index = (self.proxy_index + 1) % len(self.proxy_list)
        
        request.meta[&amp;#39;proxy&amp;#39;] = proxy
        spider.logger.debug(f&amp;#39;Using proxy: {proxy}&amp;#39;)

class RetryWithBackoffMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Implement exponential backoff for retries&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, max_retry_times=3, initial_delay=1):
        self.max_retry_times = max_retry_times
        self.initial_delay = initial_delay
    
    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            max_retry_times=crawler.settings.getint(&amp;#39;RETRY_TIMES&amp;#39;, 3),
            initial_delay=crawler.settings.getfloat(&amp;#39;RETRY_INITIAL_DELAY&amp;#39;, 1)
        )
    
    def process_response(self, request, response, spider):
        if response.status in [429, 503, 502, 504]:  # Rate limited or server errors
            retry_times = request.meta.get(&amp;#39;retry_times&amp;#39;, 0)
            
            if retry_times &amp;lt; self.max_retry_times:
                # Calculate exponential backoff delay
                delay = self.initial_delay * (2 ** retry_times)
                spider.logger.info(f&amp;#39;Retrying {request.url} after {delay}s (attempt {retry_times + 1})&amp;#39;)
                
                # Add delay
                time.sleep(delay)
                
                # Create retry request
                retry_request = request.copy()
                retry_request.meta[&amp;#39;retry_times&amp;#39;] = retry_times + 1
                retry_request.dont_filter = True
                
                return retry_request
        
        return response

class HeaderRotationMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Rotate request headers to appear more natural&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.header_sets = [
            {
                &amp;#39;Accept&amp;#39;: &amp;#39;text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8&amp;#39;,
                &amp;#39;Accept-Language&amp;#39;: &amp;#39;en-US,en;q=0.5&amp;#39;,
                &amp;#39;Accept-Encoding&amp;#39;: &amp;#39;gzip, deflate&amp;#39;,
                &amp;#39;DNT&amp;#39;: &amp;#39;1&amp;#39;,
                &amp;#39;Connection&amp;#39;: &amp;#39;keep-alive&amp;#39;,
                &amp;#39;Upgrade-Insecure-Requests&amp;#39;: &amp;#39;1&amp;#39;,
            },
            {
                &amp;#39;Accept&amp;#39;: &amp;#39;text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8&amp;#39;,
                &amp;#39;Accept-Language&amp;#39;: &amp;#39;en-US,en;q=0.9&amp;#39;,
                &amp;#39;Accept-Encoding&amp;#39;: &amp;#39;gzip, deflate, br&amp;#39;,
                &amp;#39;Connection&amp;#39;: &amp;#39;keep-alive&amp;#39;,
                &amp;#39;Sec-Fetch-Dest&amp;#39;: &amp;#39;document&amp;#39;,
                &amp;#39;Sec-Fetch-Mode&amp;#39;: &amp;#39;navigate&amp;#39;,
                &amp;#39;Sec-Fetch-Site&amp;#39;: &amp;#39;none&amp;#39;,
            }
        ]
    
    def process_request(self, request, spider):
        headers = random.choice(self.header_sets)
        for key, value in headers.items():
            request.headers[key] = value
        
        return None
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Infinite Scroll and Dynamic Loading&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/spiders/infinite_scroll_spider.py
import scrapy
from scrapy_splash import SplashRequest
import json

class InfiniteScrollSpider(scrapy.Spider):
    name = &amp;#39;infinite_scroll&amp;#39;
    allowed_domains = [&amp;#39;infinite-example.com&amp;#39;]
    
    def start_requests(self):
        lua_script = &amp;quot;&amp;quot;&amp;quot;
        function main(splash, args)
            splash:go(args.url)
            splash:wait(2)
            
            -- Function to scroll and wait for content
            local function scroll_and_wait(times)
                for i = 1, times do
                    splash:runjs([[
                        window.scrollTo(0, document.body.scrollHeight);
                    ]])
                    splash:wait(2)
                    
                    -- Check if &amp;quot;Load More&amp;quot; button exists and click it
                    local load_more = splash:select(&amp;#39;.load-more&amp;#39;)
                    if load_more then
                        load_more:click()
                        splash:wait(3)
                    end
                    
                    -- Check if reached end
                    local end_marker = splash:select(&amp;#39;.end-of-content&amp;#39;)
                    if end_marker then
                        break
                    end
                end
            end
            
            -- Scroll multiple times to load content
            scroll_and_wait(5)
            
            return {
                html = splash:html(),
                url = splash:url()
            }
        end
        &amp;quot;&amp;quot;&amp;quot;
        
        yield SplashRequest(
            url=&amp;#39;https://infinite-example.com/products&amp;#39;,
            callback=self.parse,
            args={
                &amp;#39;lua_source&amp;#39;: lua_script,
                &amp;#39;timeout&amp;#39;: 60
            }
        )
    
    def parse(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse infinite scroll content&amp;quot;&amp;quot;&amp;quot;
        products = response.css(&amp;#39;.product-item&amp;#39;)
        
        for product in products:
            yield {
                &amp;#39;name&amp;#39;: product.css(&amp;#39;.product-name::text&amp;#39;).get(),
                &amp;#39;price&amp;#39;: product.css(&amp;#39;.price::text&amp;#39;).get(),
                &amp;#39;url&amp;#39;: response.urljoin(product.css(&amp;#39;a::attr(href)&amp;#39;).get())
            }
        
        # Look for AJAX endpoints to continue pagination
        self.extract_ajax_pagination(response)
    
    def extract_ajax_pagination(self, response):
        &amp;quot;&amp;quot;&amp;quot;Extract AJAX pagination endpoints&amp;quot;&amp;quot;&amp;quot;
        import re
        
        # Look for pagination API endpoints in JavaScript
        ajax_patterns = [
            r&amp;#39;loadMore[\&amp;#39;\&amp;quot;]\s*:\s*[\&amp;#39;\&amp;quot;](.*?)[\&amp;#39;\&amp;quot;&amp;#39;,
            r&amp;#39;pagination[\&amp;#39;\&amp;quot;]\s*:\s*[\&amp;#39;\&amp;quot;](.*?)[\&amp;#39;\&amp;quot;&amp;#39;,
            r&amp;#39;nextPage[\&amp;#39;\&amp;quot;]\s*:\s*[\&amp;#39;\&amp;quot;](.*?)[\&amp;#39;\&amp;quot;&amp;#39;
        ]
        
        for pattern in ajax_patterns:
            matches = re.findall(pattern, response.text)
            for match in matches:
                if match.startswith(&amp;#39;http&amp;#39;) or match.startswith(&amp;#39;/&amp;#39;):
                    yield scrapy.Request(
                        url=response.urljoin(match),
                        callback=self.parse_ajax_page,
                        headers={&amp;#39;X-Requested-With&amp;#39;: &amp;#39;XMLHttpRequest&amp;#39;}
                    )
    
    def parse_ajax_page(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse AJAX loaded content&amp;quot;&amp;quot;&amp;quot;
        try:
            data = json.loads(response.text)
            
            # Extract HTML content from AJAX response
            html_content = data.get(&amp;#39;html&amp;#39;, &amp;#39;&amp;#39;)
            if html_content:
                from scrapy import Selector
                selector = Selector(text=html_content)
                
                products = selector.css(&amp;#39;.product-item&amp;#39;)
                for product in products:
                    yield {
                        &amp;#39;name&amp;#39;: product.css(&amp;#39;.product-name::text&amp;#39;).get(),
                        &amp;#39;price&amp;#39;: product.css(&amp;#39;.price::text&amp;#39;).get(),
                        &amp;#39;ajax_source&amp;#39;: True
                    }
            
            # Continue pagination if available
            next_page = data.get(&amp;#39;nextPage&amp;#39;)
            if next_page:
                yield response.follow(
                    next_page,
                    callback=self.parse_ajax_page
                )
                
        except json.JSONDecodeError:
            self.logger.error(f&amp;#39;Invalid JSON from {response.url}&amp;#39;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;You can now render JavaScript pages through Splash, authenticate and keep sessions, pull data straight from AJAX endpoints, and write middleware for rotation and retries. That handles most sites; the ones that actively fight back are next.&lt;/p&gt;
&lt;h3&gt;What&amp;#39;s Next?&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;/tutorials/web-scraping-scrapy-part-3&quot;&gt;Part 3: Anti-Detection and Scaling&lt;/a&gt; covers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Advanced anti-detection techniques&lt;/li&gt;
&lt;li&gt;Distributed scraping with Scrapy-Redis&lt;/li&gt;
&lt;li&gt;Monitoring and alerting systems&lt;/li&gt;
&lt;li&gt;Performance optimization strategies&lt;/li&gt;
&lt;li&gt;Legal compliance and ethical scraping&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Practice Exercise&lt;/h3&gt;
&lt;p&gt;Build a spider that can handle a modern e-commerce site with:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;JavaScript-rendered product listings&lt;/li&gt;
&lt;li&gt;User authentication for member prices&lt;/li&gt;
&lt;li&gt;AJAX-loaded reviews and ratings&lt;/li&gt;
&lt;li&gt;Infinite scroll pagination&lt;/li&gt;
&lt;li&gt;Form-based search functionality&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>Scrapy, part 1: fundamentals and your first spider</title><link>https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-1/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-1/</guid><description>Scrapy&apos;s architecture, selectors, and items, ending with a first spider that extracts real data without falling over.</description><pubDate>Sat, 21 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Scrapy fundamentals and your first spider&lt;/h1&gt;
&lt;p&gt;This is part 1 of a five-part series on Scrapy. By the end of it you&amp;#39;ll have a working project: a spider that crawls an e-commerce catalog, item loaders that clean the extracted data, and pipelines that validate and store it.&lt;/p&gt;
&lt;h2&gt;Series Overview&lt;/h2&gt;
&lt;p&gt;The full series:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Part 1: Scrapy Fundamentals&lt;/strong&gt; (This tutorial)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 2: Advanced Scraping Techniques&lt;/strong&gt; &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 3: Anti-Detection and Scaling&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 4: Data Processing and Storage&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 5: Production Deployment&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;What this part covers&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Web scraping ethics and legality&lt;/li&gt;
&lt;li&gt;Setting up a Scrapy development environment&lt;/li&gt;
&lt;li&gt;Building a first spider with a sane structure&lt;/li&gt;
&lt;li&gt;Data extraction using selectors and XPath&lt;/li&gt;
&lt;li&gt;Handling different data types and edge cases&lt;/li&gt;
&lt;li&gt;Patterns for maintainable scraping code&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Understanding Web Scraping&lt;/h2&gt;
&lt;h3&gt;What is Web Scraping?&lt;/h3&gt;
&lt;p&gt;Web scraping is the automated extraction of data from websites: a program fetches pages and pulls out the fields you tell it to.&lt;/p&gt;
&lt;h3&gt;When to Use Web Scraping&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Common use cases for web scraping:
use_cases = {
    &amp;quot;e_commerce&amp;quot;: [
        &amp;quot;Price monitoring and comparison&amp;quot;,
        &amp;quot;Product catalog aggregation&amp;quot;, 
        &amp;quot;Inventory tracking&amp;quot;,
        &amp;quot;Competitor analysis&amp;quot;
    ],
    &amp;quot;real_estate&amp;quot;: [
        &amp;quot;Property listings collection&amp;quot;,
        &amp;quot;Market price analysis&amp;quot;,
        &amp;quot;Investment opportunity identification&amp;quot;
    ],
    &amp;quot;news_media&amp;quot;: [
        &amp;quot;News aggregation&amp;quot;,
        &amp;quot;Sentiment analysis&amp;quot;,
        &amp;quot;Content monitoring&amp;quot;
    ],
    &amp;quot;research&amp;quot;: [
        &amp;quot;Academic paper collection&amp;quot;,
        &amp;quot;Social media data analysis&amp;quot;,
        &amp;quot;Market research&amp;quot;
    ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Legal and Ethical Considerations&lt;/h3&gt;
&lt;p&gt;Before writing any code, the legal and ethical ground rules:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Legal scraping checklist
legal_checklist = {
    &amp;quot;robots_txt&amp;quot;: &amp;quot;Always check and respect robots.txt&amp;quot;,
    &amp;quot;terms_of_service&amp;quot;: &amp;quot;Review website terms before scraping&amp;quot;,
    &amp;quot;rate_limiting&amp;quot;: &amp;quot;Don&amp;#39;t overload servers with requests&amp;quot;,
    &amp;quot;personal_data&amp;quot;: &amp;quot;Be careful with personal/sensitive information&amp;quot;,
    &amp;quot;copyright&amp;quot;: &amp;quot;Respect intellectual property rights&amp;quot;,
    &amp;quot;public_data&amp;quot;: &amp;quot;Focus on publicly available information&amp;quot;
}

# Ethical scraping principles
ethical_principles = [
    &amp;quot;Be respectful of website resources&amp;quot;,
    &amp;quot;Don&amp;#39;t impact site performance for other users&amp;quot;, 
    &amp;quot;Use scraped data responsibly&amp;quot;,
    &amp;quot;Give attribution when appropriate&amp;quot;,
    &amp;quot;Consider contacting site owners for large-scale scraping&amp;quot;
]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Setting Up Your Scrapy Environment&lt;/h2&gt;
&lt;h3&gt;Step 1: Python Environment Setup&lt;/h3&gt;
&lt;p&gt;Start with an isolated Python environment:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Create a virtual environment
python -m venv scrapy_env

# Activate the environment
# On Windows:
scrapy_env\Scripts\activate
# On macOS/Linux:
source scrapy_env/bin/activate

# Upgrade pip
pip install --upgrade pip
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 2: Install Scrapy and Dependencies&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install Scrapy with all recommended packages
pip install scrapy

# Install additional useful packages
pip install scrapy-splash        # For JavaScript rendering
pip install scrapy-user-agents   # For rotating user agents
pip install scrapy-rotating-proxies  # For proxy rotation
pip install itemadapter          # For item processing
pip install pymongo             # For MongoDB storage
pip install psycopg2-binary     # For PostgreSQL storage
pip install redis               # For Redis-based deduplication

# Development tools
pip install ipython             # Better REPL
pip install scrapy-shell        # Enhanced shell
pip install black               # Code formatting
pip install flake8             # Linting

# Save requirements
pip freeze &amp;gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 3: Create Project Structure&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Create a new Scrapy project
scrapy startproject webscraper

# Navigate to project directory
cd webscraper

# Create additional directories for organization
mkdir -p data/raw data/processed data/exports
mkdir -p logs
mkdir -p scripts
mkdir -p tests
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your project structure should look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;webscraper/
├── scrapy.cfg                 # Deploy configuration
├── requirements.txt           # Python dependencies
├── data/                      # Data storage
│   ├── raw/                  # Raw scraped data
│   ├── processed/            # Cleaned data
│   └── exports/              # Final exports
├── logs/                     # Log files
├── scripts/                  # Utility scripts
├── tests/                    # Test files
└── webscraper/               # Main package
    ├── __init__.py
    ├── items.py              # Item definitions
    ├── middlewares.py        # Custom middlewares
    ├── pipelines.py          # Data processing pipelines
    ├── settings.py           # Project settings
    └── spiders/              # Spider modules
        └── __init__.py
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Building Your First Spider&lt;/h2&gt;
&lt;h3&gt;Step 1: Define Data Items&lt;/h3&gt;
&lt;p&gt;First, let&amp;#39;s define what data we want to extract. We&amp;#39;ll build a spider for scraping e-commerce products:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/items.py
import scrapy
from itemloaders.processors import TakeFirst, MapCompose, Join
from w3lib.html import remove_tags


def clean_price(value):
    &amp;quot;&amp;quot;&amp;quot;Clean price string and convert to float&amp;quot;&amp;quot;&amp;quot;
    if value:
        # Remove currency symbols and whitespace
        cleaned = &amp;#39;&amp;#39;.join(char for char in value if char.isdigit() or char == &amp;#39;.&amp;#39;)
        try:
            return float(cleaned)
        except ValueError:
            return None
    return None


def clean_text(value):
    &amp;quot;&amp;quot;&amp;quot;Clean text by removing extra whitespace and HTML tags&amp;quot;&amp;quot;&amp;quot;
    if value:
        cleaned = remove_tags(value).strip()
        return &amp;#39; &amp;#39;.join(cleaned.split())
    return None


class ProductItem(scrapy.Item):
    # Basic product information
    name = scrapy.Field(
        input_processor=MapCompose(clean_text),
        output_processor=TakeFirst()
    )
    
    price = scrapy.Field(
        input_processor=MapCompose(clean_price),
        output_processor=TakeFirst()
    )
    
    original_price = scrapy.Field(
        input_processor=MapCompose(clean_price),
        output_processor=TakeFirst()
    )
    
    currency = scrapy.Field(
        output_processor=TakeFirst()
    )
    
    description = scrapy.Field(
        input_processor=MapCompose(clean_text),
        output_processor=Join(&amp;#39; &amp;#39;)
    )
    
    # Product details
    brand = scrapy.Field(
        input_processor=MapCompose(clean_text),
        output_processor=TakeFirst()
    )
    
    category = scrapy.Field(
        input_processor=MapCompose(clean_text),
        output_processor=TakeFirst()
    )
    
    sku = scrapy.Field(
        output_processor=TakeFirst()
    )
    
    availability = scrapy.Field(
        output_processor=TakeFirst()
    )
    
    rating = scrapy.Field(
        output_processor=TakeFirst()
    )
    
    review_count = scrapy.Field(
        output_processor=TakeFirst()
    )
    
    # Images and media
    images = scrapy.Field()
    
    # Metadata
    url = scrapy.Field(
        output_processor=TakeFirst()
    )
    
    scraped_at = scrapy.Field(
        output_processor=TakeFirst()
    )
    
    # Additional fields for tracking
    source = scrapy.Field(
        output_processor=TakeFirst()
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 2: Create Your First Spider&lt;/h3&gt;
&lt;p&gt;Now the spider itself. It uses fallback selectors and structured data so a single layout change doesn&amp;#39;t break extraction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/spiders/ecommerce_spider.py
import scrapy
from scrapy.loader import ItemLoader
from webscraper.items import ProductItem
from datetime import datetime
import json
import re


class EcommerceSpider(scrapy.Spider):
    name = &amp;#39;ecommerce&amp;#39;
    allowed_domains = [&amp;#39;example-store.com&amp;#39;]
    
    # Custom settings for this spider
    custom_settings = {
        &amp;#39;DOWNLOAD_DELAY&amp;#39;: 1,
        &amp;#39;RANDOMIZE_DOWNLOAD_DELAY&amp;#39;: True,
        &amp;#39;AUTOTHROTTLE_ENABLED&amp;#39;: True,
        &amp;#39;AUTOTHROTTLE_START_DELAY&amp;#39;: 0.5,
        &amp;#39;AUTOTHROTTLE_MAX_DELAY&amp;#39;: 3,
        &amp;#39;AUTOTHROTTLE_TARGET_CONCURRENCY&amp;#39;: 2.0,
        &amp;#39;FEEDS&amp;#39;: {
            &amp;#39;data/raw/products_%(time)s.json&amp;#39;: {
                &amp;#39;format&amp;#39;: &amp;#39;json&amp;#39;,
                &amp;#39;encoding&amp;#39;: &amp;#39;utf8&amp;#39;,
                &amp;#39;store_empty&amp;#39;: False,
                &amp;#39;fields&amp;#39;: None,
                &amp;#39;indent&amp;#39;: 2,
            },
        }
    }
    
    def start_requests(self):
        &amp;quot;&amp;quot;&amp;quot;Generate initial requests&amp;quot;&amp;quot;&amp;quot;
        start_urls = [
            &amp;#39;https://example-store.com/products&amp;#39;,
            &amp;#39;https://example-store.com/categories/electronics&amp;#39;,
            &amp;#39;https://example-store.com/categories/clothing&amp;#39;,
        ]
        
        for url in start_urls:
            yield scrapy.Request(
                url=url,
                callback=self.parse,
                meta={
                    &amp;#39;source&amp;#39;: &amp;#39;category_page&amp;#39;,
                    &amp;#39;playwright&amp;#39;: True,  # Enable JavaScript rendering if needed
                }
            )
    
    def parse(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse category pages and extract product links&amp;quot;&amp;quot;&amp;quot;
        self.logger.info(f&amp;#39;Parsing category page: {response.url}&amp;#39;)
        
        # Extract product links using CSS selectors
        product_links = response.css(&amp;#39;.product-item a::attr(href)&amp;#39;).getall()
        
        if not product_links:
            # Try alternative selectors
            product_links = response.css(&amp;#39;.product-link::attr(href)&amp;#39;).getall()
        
        # Follow product links
        for link in product_links:
            product_url = response.urljoin(link)
            yield scrapy.Request(
                url=product_url,
                callback=self.parse_product,
                meta={
                    &amp;#39;source&amp;#39;: &amp;#39;product_page&amp;#39;,
                    &amp;#39;category_url&amp;#39;: response.url
                }
            )
        
        # Follow pagination
        next_page = response.css(&amp;#39;.pagination .next::attr(href)&amp;#39;).get()
        if next_page:
            yield scrapy.Request(
                url=response.urljoin(next_page),
                callback=self.parse,
                meta=response.meta
            )
    
    def parse_product(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse individual product pages&amp;quot;&amp;quot;&amp;quot;
        self.logger.info(f&amp;#39;Parsing product: {response.url}&amp;#39;)
        
        # Create item loader for clean data extraction
        loader = ItemLoader(item=ProductItem(), response=response)
        
        # Basic product information
        loader.add_css(&amp;#39;name&amp;#39;, &amp;#39;h1.product-title::text&amp;#39;)
        loader.add_css(&amp;#39;name&amp;#39;, &amp;#39;.product-name::text&amp;#39;)  # Fallback selector
        
        # Price extraction with multiple selectors
        loader.add_css(&amp;#39;price&amp;#39;, &amp;#39;.price-current::text&amp;#39;)
        loader.add_css(&amp;#39;price&amp;#39;, &amp;#39;.current-price::text&amp;#39;)
        loader.add_xpath(&amp;#39;price&amp;#39;, &amp;#39;//span[@class=&amp;quot;price&amp;quot;]//text()&amp;#39;)
        
        # Original price (if on sale)
        loader.add_css(&amp;#39;original_price&amp;#39;, &amp;#39;.price-original::text&amp;#39;)
        loader.add_css(&amp;#39;original_price&amp;#39;, &amp;#39;.old-price::text&amp;#39;)
        
        # Product description
        loader.add_css(&amp;#39;description&amp;#39;, &amp;#39;.product-description p::text&amp;#39;)
        loader.add_xpath(&amp;#39;description&amp;#39;, &amp;#39;//div[@class=&amp;quot;description&amp;quot;]//text()&amp;#39;)
        
        # Product details
        loader.add_css(&amp;#39;brand&amp;#39;, &amp;#39;.brand-name::text&amp;#39;)
        loader.add_css(&amp;#39;category&amp;#39;, &amp;#39;.breadcrumb li:last-child::text&amp;#39;)
        loader.add_css(&amp;#39;sku&amp;#39;, &amp;#39;.product-sku::text&amp;#39;)
        
        # Availability
        availability = response.css(&amp;#39;.stock-status::text&amp;#39;).get()
        if availability:
            loader.add_value(&amp;#39;availability&amp;#39;, &amp;#39;in_stock&amp;#39; if &amp;#39;in stock&amp;#39; in availability.lower() else &amp;#39;out_of_stock&amp;#39;)
        
        # Rating and reviews
        rating = response.css(&amp;#39;.rating-value::text&amp;#39;).get()
        if rating:
            loader.add_value(&amp;#39;rating&amp;#39;, float(rating))
        
        review_count_text = response.css(&amp;#39;.review-count::text&amp;#39;).get()
        if review_count_text:
            review_count = re.search(r&amp;#39;(\d+)&amp;#39;, review_count_text)
            if review_count:
                loader.add_value(&amp;#39;review_count&amp;#39;, int(review_count.group(1)))
        
        # Images
        image_urls = response.css(&amp;#39;.product-images img::attr(src)&amp;#39;).getall()
        if image_urls:
            # Convert relative URLs to absolute
            absolute_urls = [response.urljoin(url) for url in image_urls]
            loader.add_value(&amp;#39;images&amp;#39;, absolute_urls)
        
        # Metadata
        loader.add_value(&amp;#39;url&amp;#39;, response.url)
        loader.add_value(&amp;#39;scraped_at&amp;#39;, datetime.now().isoformat())
        loader.add_value(&amp;#39;source&amp;#39;, response.meta.get(&amp;#39;source&amp;#39;, &amp;#39;unknown&amp;#39;))
        
        # Extract structured data if available
        structured_data = self.extract_structured_data(response)
        if structured_data:
            self.update_loader_from_structured_data(loader, structured_data)
        
        yield loader.load_item()
    
    def extract_structured_data(self, response):
        &amp;quot;&amp;quot;&amp;quot;Extract JSON-LD structured data&amp;quot;&amp;quot;&amp;quot;
        scripts = response.xpath(&amp;#39;//script[@type=&amp;quot;application/ld+json&amp;quot;]/text()&amp;#39;).getall()
        
        for script in scripts:
            try:
                data = json.loads(script)
                if isinstance(data, dict) and data.get(&amp;#39;@type&amp;#39;) == &amp;#39;Product&amp;#39;:
                    return data
                elif isinstance(data, list):
                    for item in data:
                        if isinstance(item, dict) and item.get(&amp;#39;@type&amp;#39;) == &amp;#39;Product&amp;#39;:
                            return item
            except json.JSONDecodeError:
                continue
        
        return None
    
    def update_loader_from_structured_data(self, loader, data):
        &amp;quot;&amp;quot;&amp;quot;Update item loader with structured data&amp;quot;&amp;quot;&amp;quot;
        if &amp;#39;name&amp;#39; in data:
            loader.add_value(&amp;#39;name&amp;#39;, data[&amp;#39;name&amp;#39;])
        
        if &amp;#39;offers&amp;#39; in data and isinstance(data[&amp;#39;offers&amp;#39;], dict):
            offer = data[&amp;#39;offers&amp;#39;]
            if &amp;#39;price&amp;#39; in offer:
                loader.add_value(&amp;#39;price&amp;#39;, float(offer[&amp;#39;price&amp;#39;]))
            if &amp;#39;priceCurrency&amp;#39; in offer:
                loader.add_value(&amp;#39;currency&amp;#39;, offer[&amp;#39;priceCurrency&amp;#39;])
            if &amp;#39;availability&amp;#39; in offer:
                availability = offer[&amp;#39;availability&amp;#39;].split(&amp;#39;/&amp;#39;)[-1].lower()
                loader.add_value(&amp;#39;availability&amp;#39;, availability)
        
        if &amp;#39;brand&amp;#39; in data:
            brand = data[&amp;#39;brand&amp;#39;]
            if isinstance(brand, dict) and &amp;#39;name&amp;#39; in brand:
                loader.add_value(&amp;#39;brand&amp;#39;, brand[&amp;#39;name&amp;#39;])
            elif isinstance(brand, str):
                loader.add_value(&amp;#39;brand&amp;#39;, brand)
        
        if &amp;#39;aggregateRating&amp;#39; in data:
            rating_data = data[&amp;#39;aggregateRating&amp;#39;]
            if &amp;#39;ratingValue&amp;#39; in rating_data:
                loader.add_value(&amp;#39;rating&amp;#39;, float(rating_data[&amp;#39;ratingValue&amp;#39;]))
            if &amp;#39;reviewCount&amp;#39; in rating_data:
                loader.add_value(&amp;#39;review_count&amp;#39;, int(rating_data[&amp;#39;reviewCount&amp;#39;]))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 3: Data Processing Pipeline&lt;/h3&gt;
&lt;p&gt;Create a pipeline to process and validate extracted data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/pipelines.py
from itemadapter import ItemAdapter
import logging
import json
from datetime import datetime


class ValidationPipeline:
    &amp;quot;&amp;quot;&amp;quot;Validate and clean scraped items&amp;quot;&amp;quot;&amp;quot;
    
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        
        # Validate required fields
        required_fields = [&amp;#39;name&amp;#39;, &amp;#39;url&amp;#39;]
        for field in required_fields:
            if not adapter.get(field):
                raise DropItem(f&amp;quot;Missing required field: {field}&amp;quot;)
        
        # Clean and validate price
        price = adapter.get(&amp;#39;price&amp;#39;)
        if price is not None:
            if not isinstance(price, (int, float)) or price &amp;lt; 0:
                spider.logger.warning(f&amp;quot;Invalid price for {adapter[&amp;#39;name&amp;#39;]}: {price}&amp;quot;)
                adapter[&amp;#39;price&amp;#39;] = None
        
        # Validate rating
        rating = adapter.get(&amp;#39;rating&amp;#39;)
        if rating is not None:
            if not isinstance(rating, (int, float)) or not (0 &amp;lt;= rating &amp;lt;= 5):
                spider.logger.warning(f&amp;quot;Invalid rating for {adapter[&amp;#39;name&amp;#39;]}: {rating}&amp;quot;)
                adapter[&amp;#39;rating&amp;#39;] = None
        
        return item


class DuplicationFilterPipeline:
    &amp;quot;&amp;quot;&amp;quot;Filter out duplicate items&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.seen_items = set()
    
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        
        # Create a unique identifier for the item
        identifier = f&amp;quot;{adapter[&amp;#39;name&amp;#39;]}_{adapter[&amp;#39;url&amp;#39;]}&amp;quot;
        
        if identifier in self.seen_items:
            raise DropItem(f&amp;quot;Duplicate item found: {adapter[&amp;#39;name&amp;#39;]}&amp;quot;)
        else:
            self.seen_items.add(identifier)
            return item


class JsonWriterPipeline:
    &amp;quot;&amp;quot;&amp;quot;Write items to JSON file&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.file = None
        self.items = []
    
    def open_spider(self, spider):
        timestamp = datetime.now().strftime(&amp;quot;%Y%m%d_%H%M%S&amp;quot;)
        filename = f&amp;quot;data/processed/{spider.name}_{timestamp}.json&amp;quot;
        self.file = open(filename, &amp;#39;w&amp;#39;, encoding=&amp;#39;utf-8&amp;#39;)
        spider.logger.info(f&amp;quot;Opened file: {filename}&amp;quot;)
    
    def close_spider(self, spider):
        if self.file:
            json.dump(self.items, self.file, indent=2, ensure_ascii=False)
            self.file.close()
            spider.logger.info(f&amp;quot;Saved {len(self.items)} items&amp;quot;)
    
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        self.items.append(dict(adapter))
        return item


class StatisticsPipeline:
    &amp;quot;&amp;quot;&amp;quot;Collect scraping statistics&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.stats = {
            &amp;#39;items_scraped&amp;#39;: 0,
            &amp;#39;items_dropped&amp;#39;: 0,
            &amp;#39;start_time&amp;#39;: None,
            &amp;#39;end_time&amp;#39;: None
        }
    
    def open_spider(self, spider):
        self.stats[&amp;#39;start_time&amp;#39;] = datetime.now()
        spider.logger.info(&amp;quot;Statistics collection started&amp;quot;)
    
    def close_spider(self, spider):
        self.stats[&amp;#39;end_time&amp;#39;] = datetime.now()
        duration = self.stats[&amp;#39;end_time&amp;#39;] - self.stats[&amp;#39;start_time&amp;#39;]
        
        spider.logger.info(&amp;quot;=== SCRAPING STATISTICS ===&amp;quot;)
        spider.logger.info(f&amp;quot;Items scraped: {self.stats[&amp;#39;items_scraped&amp;#39;]}&amp;quot;)
        spider.logger.info(f&amp;quot;Items dropped: {self.stats[&amp;#39;items_dropped&amp;#39;]}&amp;quot;)
        spider.logger.info(f&amp;quot;Duration: {duration}&amp;quot;)
        spider.logger.info(f&amp;quot;Items per minute: {self.stats[&amp;#39;items_scraped&amp;#39;] / (duration.total_seconds() / 60):.2f}&amp;quot;)
    
    def process_item(self, item, spider):
        self.stats[&amp;#39;items_scraped&amp;#39;] += 1
        return item
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 4: Configure Settings&lt;/h3&gt;
&lt;p&gt;Update the project settings:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/settings.py

# Scrapy settings for webscraper project
BOT_NAME = &amp;#39;webscraper&amp;#39;

SPIDER_MODULES = [&amp;#39;webscraper.spiders&amp;#39;]
NEWSPIDER_MODULE = &amp;#39;webscraper.spiders&amp;#39;

# Obey robots.txt rules
ROBOTSTXT_OBEY = True

# Configure pipelines
ITEM_PIPELINES = {
    &amp;#39;webscraper.pipelines.ValidationPipeline&amp;#39;: 300,
    &amp;#39;webscraper.pipelines.DuplicationFilterPipeline&amp;#39;: 400,
    &amp;#39;webscraper.pipelines.JsonWriterPipeline&amp;#39;: 500,
    &amp;#39;webscraper.pipelines.StatisticsPipeline&amp;#39;: 600,
}

# Configure delays and throttling
DOWNLOAD_DELAY = 1
RANDOMIZE_DOWNLOAD_DELAY = True

# AutoThrottle settings
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 0.5
AUTOTHROTTLE_MAX_DELAY = 10
AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0
AUTOTHROTTLE_DEBUG = False

# User agent settings
USER_AGENT = &amp;#39;webscraper (+http://www.yourdomain.com)&amp;#39;

# Configure caching
HTTPCACHE_ENABLED = True
HTTPCACHE_EXPIRATION_SECS = 3600
HTTPCACHE_DIR = &amp;#39;httpcache&amp;#39;

# Logging settings
LOG_LEVEL = &amp;#39;INFO&amp;#39;
LOG_FILE = &amp;#39;logs/scrapy.log&amp;#39;

# Retry settings
RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429]

# Concurrent requests
CONCURRENT_REQUESTS = 16
CONCURRENT_REQUESTS_PER_DOMAIN = 8

# Memory usage optimization
MEMUSAGE_ENABLED = True
MEMUSAGE_LIMIT_MB = 2048
MEMUSAGE_WARNING_MB = 1024

# Request and response size limits
DOWNLOAD_MAXSIZE = 1073741824  # 1GB
DOWNLOAD_WARNSIZE = 33554432   # 32MB
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Running Your Spider&lt;/h2&gt;
&lt;h3&gt;Basic Execution&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Run the spider
scrapy crawl ecommerce

# Run with custom settings
scrapy crawl ecommerce -s DOWNLOAD_DELAY=2

# Save output to specific file
scrapy crawl ecommerce -o products.json

# Run with custom log level
scrapy crawl ecommerce -L DEBUG
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Advanced Execution with Parameters&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Create a script to run spiders with parameters
# scripts/run_spider.py

import subprocess
import sys
from datetime import datetime

def run_spider(spider_name, **kwargs):
    &amp;quot;&amp;quot;&amp;quot;Run spider with custom parameters&amp;quot;&amp;quot;&amp;quot;
    
    cmd = [&amp;#39;scrapy&amp;#39;, &amp;#39;crawl&amp;#39;, spider_name]
    
    # Add custom settings
    for key, value in kwargs.items():
        cmd.extend([&amp;#39;-s&amp;#39;, f&amp;#39;{key}={value}&amp;#39;])
    
    # Add timestamp to output file
    timestamp = datetime.now().strftime(&amp;quot;%Y%m%d_%H%M%S&amp;quot;)
    output_file = f&amp;#39;data/raw/{spider_name}_{timestamp}.json&amp;#39;
    cmd.extend([&amp;#39;-o&amp;#39;, output_file])
    
    print(f&amp;quot;Running command: {&amp;#39; &amp;#39;.join(cmd)}&amp;quot;)
    
    try:
        result = subprocess.run(cmd, check=True, capture_output=True, text=True)
        print(&amp;quot;Spider completed successfully!&amp;quot;)
        print(f&amp;quot;Output saved to: {output_file}&amp;quot;)
        return True
    except subprocess.CalledProcessError as e:
        print(f&amp;quot;Spider failed with error: {e}&amp;quot;)
        print(f&amp;quot;Error output: {e.stderr}&amp;quot;)
        return False

if __name__ == &amp;quot;__main__&amp;quot;:
    # Example usage
    run_spider(
        &amp;#39;ecommerce&amp;#39;,
        DOWNLOAD_DELAY=1.5,
        CONCURRENT_REQUESTS=8,
        LOG_LEVEL=&amp;#39;INFO&amp;#39;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Testing Your Spider&lt;/h2&gt;
&lt;h3&gt;Unit Tests&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# tests/test_ecommerce_spider.py
import unittest
from scrapy.http import HtmlResponse, Request
from webscraper.spiders.ecommerce_spider import EcommerceSpider

class TestEcommerceSpider(unittest.TestCase):
    
    def setUp(self):
        self.spider = EcommerceSpider()
    
    def test_parse_product(self):
        &amp;quot;&amp;quot;&amp;quot;Test product parsing&amp;quot;&amp;quot;&amp;quot;
        # Sample HTML response
        html = &amp;quot;&amp;quot;&amp;quot;
        &amp;lt;html&amp;gt;
        &amp;lt;body&amp;gt;
            &amp;lt;h1 class=&amp;quot;product-title&amp;quot;&amp;gt;Test Product&amp;lt;/h1&amp;gt;
            &amp;lt;span class=&amp;quot;price-current&amp;quot;&amp;gt;$99.99&amp;lt;/span&amp;gt;
            &amp;lt;p class=&amp;quot;product-description&amp;quot;&amp;gt;This is a test product&amp;lt;/p&amp;gt;
        &amp;lt;/body&amp;gt;
        &amp;lt;/html&amp;gt;
        &amp;quot;&amp;quot;&amp;quot;
        
        request = Request(url=&amp;#39;http://example.com/product/1&amp;#39;)
        response = HtmlResponse(
            url=&amp;#39;http://example.com/product/1&amp;#39;,
            request=request,
            body=html.encode(&amp;#39;utf-8&amp;#39;)
        )
        
        # Process the response
        items = list(self.spider.parse_product(response))
        
        # Assertions
        self.assertEqual(len(items), 1)
        item = items[0]
        self.assertEqual(item[&amp;#39;name&amp;#39;], &amp;#39;Test Product&amp;#39;)
        self.assertEqual(item[&amp;#39;price&amp;#39;], 99.99)

if __name__ == &amp;#39;__main__&amp;#39;:
    unittest.main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Tips&lt;/h2&gt;
&lt;h3&gt;1. Selector fallbacks&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def extract_with_fallbacks(response, selectors):
    &amp;quot;&amp;quot;&amp;quot;Extract data with multiple fallback selectors&amp;quot;&amp;quot;&amp;quot;
    for selector in selectors:
        result = response.css(selector).get()
        if result:
            return result.strip()
    return None

# Usage example
price = extract_with_fallbacks(response, [
    &amp;#39;.price-current::text&amp;#39;,
    &amp;#39;.current-price::text&amp;#39;, 
    &amp;#39;.price::text&amp;#39;,
    &amp;#39;[data-price]::attr(data-price)&amp;#39;
])
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Error Handling&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def safe_extract_float(value, default=None):
    &amp;quot;&amp;quot;&amp;quot;Safely extract float from string&amp;quot;&amp;quot;&amp;quot;
    if not value:
        return default
    
    try:
        # Clean the string
        cleaned = &amp;#39;&amp;#39;.join(char for char in str(value) if char.isdigit() or char in &amp;#39;.-&amp;#39;)
        return float(cleaned)
    except (ValueError, TypeError):
        return default
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Logging and Monitoring&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Add custom logging to your spider
import logging

class EcommerceSpider(scrapy.Spider):
    
    def __init__(self):
        self.stats = {
            &amp;#39;products_found&amp;#39;: 0,
            &amp;#39;products_processed&amp;#39;: 0,
            &amp;#39;errors&amp;#39;: 0
        }
    
    def parse_product(self, response):
        try:
            self.stats[&amp;#39;products_found&amp;#39;] += 1
            # ... processing logic ...
            self.stats[&amp;#39;products_processed&amp;#39;] += 1
            
        except Exception as e:
            self.stats[&amp;#39;errors&amp;#39;] += 1
            self.logger.error(f&amp;quot;Error processing {response.url}: {e}&amp;quot;)
    
    def closed(self, reason):
        self.logger.info(f&amp;quot;Spider closed: {reason}&amp;quot;)
        self.logger.info(f&amp;quot;Statistics: {self.stats}&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;You now have a working Scrapy project: an isolated environment, a spider that extracts data through fallback selectors and JSON-LD, pipelines for validation and deduplication, and a unit test to keep it honest.&lt;/p&gt;
&lt;h3&gt;What&amp;#39;s Next?&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;/tutorials/web-scraping-scrapy-part-2&quot;&gt;Part 2: Advanced Scraping Techniques&lt;/a&gt; covers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Handling JavaScript-heavy websites with Splash&lt;/li&gt;
&lt;li&gt;Form submission and login handling&lt;/li&gt;
&lt;li&gt;Advanced selector techniques and data extraction&lt;/li&gt;
&lt;li&gt;Handling AJAX requests and dynamic content&lt;/li&gt;
&lt;li&gt;Custom middleware development&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Practice Exercise&lt;/h3&gt;
&lt;p&gt;Before moving to Part 2, try building a spider for your favorite e-commerce site:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a new spider targeting a simple e-commerce site&lt;/li&gt;
&lt;li&gt;Extract product names, prices, and descriptions&lt;/li&gt;
&lt;li&gt;Implement proper error handling and logging&lt;/li&gt;
&lt;li&gt;Add data validation pipelines&lt;/li&gt;
&lt;li&gt;Test your spider with different product categories&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.scrapy.org/&quot;&gt;Scrapy Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.w3schools.com/xml/xpath_intro.asp&quot;&gt;XPath Tutorial&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.w3schools.com/cssref/css_selectors.asp&quot;&gt;CSS Selectors Reference&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Installing tmux on Synology, the cleaner way</title><link>https://tamangsurendra.com.np/blog/install-tmux-in-synology-new-way/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/install-tmux-in-synology-new-way/</guid><description>A cleaner way to get tmux onto a Synology NAS than my first attempt. Fewer steps, survives updates.</description><pubDate>Mon, 09 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I was working on a project where my client wanted me to put all the crawlers and applications on his own Synology NAS. It was a new thing for me. He only knew that he was able to run containers on his server. The rest was my task to figure out. Among a lot of problems at each step of this project, I ran into this one too, so I am sharing it here.&lt;/p&gt;
&lt;p&gt;Installing tmux on NAS can be frustrating.&lt;/p&gt;
&lt;p&gt;To install &lt;code&gt;tmux&lt;/code&gt; on a Synology NAS, you typically need to use package managers because Synology&amp;#39;s DiskStation Manager (DSM) doesn&amp;#39;t include &lt;code&gt;tmux&lt;/code&gt; by default. While many guides suggest using &lt;code&gt;ipkg&lt;/code&gt;, the modern approach is to use Entware with &lt;code&gt;opkg&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s a step-by-step guide on how to install &lt;code&gt;tmux&lt;/code&gt; on your Synology NAS:&lt;/p&gt;
&lt;h3&gt;Step 1: Prepare Your System&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enable SSH Access&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Open DiskStation Manager (DSM)&lt;/li&gt;
&lt;li&gt;Navigate to Control Panel → Terminal &amp;amp; SNMP&lt;/li&gt;
&lt;li&gt;Check &amp;quot;Enable SSH service&amp;quot;&lt;/li&gt;
&lt;li&gt;Click Apply&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Connect via SSH&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use your preferred SSH client to connect to your NAS&lt;/li&gt;
&lt;li&gt;Login with your admin credentials&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Step 2: Install Entware&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create Required Directories&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo mkdir -p /volume1/@entware/opt
sudo mount -o bind /volume1/@entware/opt /opt
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Download and Install Entware&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wget -O - http://bin.entware.net/x64-k3.2/installer/generic.sh | /bin/sh
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add Entware to PATH&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export PATH=/opt/bin:/opt/sbin:$PATH
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Step 3: Install tmux&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Update Package List&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;opkg update
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Install tmux&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;opkg install tmux
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Step 4: Make Installation Persistent&lt;/h3&gt;
&lt;p&gt;To ensure Entware remains available after system reboots:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create Startup Script&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo vi /usr/local/etc/rc.d/S99entware.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add Following Content&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/sh
case $1 in
  start)
    mkdir -p /volume1/@entware/opt
    mount -o bind /volume1/@entware/opt /opt
    ;;
  stop)
    umount /opt
    ;;
  *)
    echo &amp;quot;Usage: $0 [start|stop]&amp;quot;
    exit 1
    ;;
esac
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make Script Executable&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo chmod +x /usr/local/etc/rc.d/S99entware.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Basic tmux Usage&lt;/h3&gt;
&lt;p&gt;Once installed, you can start using tmux with these basic commands:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start New Session&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tmux
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create Named Session&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tmux new -s mysession
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;List Sessions&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tmux ls
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Attach to Session&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tmux attach -t mysession
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Common tmux Shortcuts&lt;/h3&gt;
&lt;p&gt;All commands are prefixed with &lt;code&gt;Ctrl+b&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;d&lt;/code&gt;: Detach from current session&lt;/li&gt;
&lt;li&gt;&lt;code&gt;c&lt;/code&gt;: Create new window&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n&lt;/code&gt;: Next window&lt;/li&gt;
&lt;li&gt;&lt;code&gt;p&lt;/code&gt;: Previous window&lt;/li&gt;
&lt;li&gt;&lt;code&gt;%&lt;/code&gt;: Split pane horizontally&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;quot;&lt;/code&gt;: Split pane vertically&lt;/li&gt;
&lt;li&gt;Arrow keys: Navigate between panes&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Troubleshooting Common Issues&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Command Not Found&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Verify Entware installation&lt;/li&gt;
&lt;li&gt;Check if PATH includes &lt;code&gt;/opt/bin&lt;/code&gt; and &lt;code&gt;/opt/sbin&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Try running &lt;code&gt;hash -r&lt;/code&gt; to clear command cache&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Permission Denied&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ensure you&amp;#39;re using sudo for administrative tasks&lt;/li&gt;
&lt;li&gt;Check file permissions on startup scripts&lt;/li&gt;
&lt;li&gt;Verify your user has sufficient privileges&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mount Point Errors&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Confirm &lt;code&gt;/volume1/@entware&lt;/code&gt; exists&lt;/li&gt;
&lt;li&gt;Check if &lt;code&gt;/opt&lt;/code&gt; is already mounted&lt;/li&gt;
&lt;li&gt;Try unmounting and remounting if necessary&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Why This Method?&lt;/h3&gt;
&lt;p&gt;While some guides suggest using older methods like &lt;code&gt;ipkg&lt;/code&gt; or the SynoCommunity package source, the Entware approach offers several advantages:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Modern Package Management&lt;/strong&gt;: Entware is actively maintained and updated&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Better Compatibility&lt;/strong&gt;: Works across different Synology models&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Larger Package Repository&lt;/strong&gt;: Access to more software packages&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Easier Maintenance&lt;/strong&gt;: Simpler updates and package management&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;With the startup script in place, Entware and tmux survive DSM reboots, which was the part missing from my first write-up. From here, tmux configs and plugins work the same as on any other Linux box.&lt;/p&gt;
</content:encoded></item><item><title>How to install tmux on a Synology NAS</title><link>https://tamangsurendra.com.np/blog/how-to-install-tmux-in-synology-nas-server/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/how-to-install-tmux-in-synology-nas-server/</guid><description>Synology&apos;s locked-down shell makes even installing tmux a small adventure. The Entware route that worked for me.</description><pubDate>Sun, 09 Jun 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I was working on a project where my client wanted me to put all the crawlers and applications on his own Synology NAS. It was a new thing for me. He only knew that he was able to run containers on his server. The rest was my task to figure out. Among a lot of problems at each step of this project, I ran into this one too, so I am sharing it here.&lt;/p&gt;
&lt;p&gt;Installing tmux on NAS can be frustrating.&lt;/p&gt;
&lt;p&gt;To install &lt;code&gt;tmux&lt;/code&gt; on a Synology NAS, you typically need to use &lt;code&gt;ipkg&lt;/code&gt; or &lt;code&gt;opkg&lt;/code&gt; because Synology&amp;#39;s DiskStation Manager (DSM) doesn’t include &lt;code&gt;tmux&lt;/code&gt; by default, and it uses a busybox-based system that lacks many common Linux utilities.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s a step-by-step guide on how to install &lt;code&gt;tmux&lt;/code&gt; on a Synology NAS using &lt;code&gt;opkg&lt;/code&gt;:&lt;/p&gt;
&lt;h3&gt;Step 1: Install Entware&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Download and Install Entware&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Entware is a software repository for embedded devices like Synology NAS. It provides a package management system similar to &lt;code&gt;apt&lt;/code&gt; on Debian or &lt;code&gt;yum&lt;/code&gt; on CentOS.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You can download and install Entware by following these steps:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wget -O - http://bin.entware.net/x64-k3.2/installer/generic.sh | /bin/sh
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Initialize Entware&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;After the installation script runs, it will set up Entware. You might need to add the Entware binary path to your &lt;code&gt;PATH&lt;/code&gt; environment variable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &amp;#39;export PATH=/opt/bin:/opt/sbin:$PATH&amp;#39; &amp;gt;&amp;gt; ~/.profile
source ~/.profile
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Step 2: Install &lt;code&gt;tmux&lt;/code&gt; using &lt;code&gt;opkg&lt;/code&gt;&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Update the Entware Package List&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Ensure you have the latest package list:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;opkg update
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Install &lt;code&gt;tmux&lt;/code&gt;&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Now, you can install &lt;code&gt;tmux&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;opkg install tmux
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verify Installation&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Check if &lt;code&gt;tmux&lt;/code&gt; is installed correctly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tmux -V
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You should see the version number of &lt;code&gt;tmux&lt;/code&gt; if it was installed successfully.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Alternative Method: Using Docker&lt;/h3&gt;
&lt;p&gt;If you prefer not to use Entware or have trouble with it, you can use Docker to run &lt;code&gt;tmux&lt;/code&gt;. Here&amp;#39;s how:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ensure Docker is Installed&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Install the Docker package from the Synology Package Center if it&amp;#39;s not already installed.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run a &lt;code&gt;tmux&lt;/code&gt; Docker Container&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;You can use an existing Docker image with &lt;code&gt;tmux&lt;/code&gt;. Pull a lightweight image like Alpine Linux:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker run -it --rm alpine:latest /bin/sh
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Inside the Docker container, install &lt;code&gt;tmux&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;apk add tmux
tmux
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Troubleshooting&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Missing Commands&lt;/strong&gt;: If you encounter errors like &lt;code&gt;command not found&lt;/code&gt;, make sure that &lt;code&gt;/opt/bin&lt;/code&gt; and &lt;code&gt;/opt/sbin&lt;/code&gt; are included in your &lt;code&gt;PATH&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Permission Issues&lt;/strong&gt;: Ensure you have root access or use &lt;code&gt;sudo&lt;/code&gt; where necessary.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compatibility&lt;/strong&gt;: Verify that your Synology NAS model supports Entware or Docker.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Summary&lt;/h3&gt;
&lt;p&gt;Installing &lt;code&gt;tmux&lt;/code&gt; on a Synology NAS comes down to installing Entware and then using &lt;code&gt;opkg&lt;/code&gt;. Entware also gives you a lot of other Linux utilities that DSM does not ship, so it is worth having anyway.&lt;/p&gt;
</content:encoded></item></channel></rss>