Piping the Web to My E-Reader

February 12, 2026

I've been using the CrossPoint Reader, an open-source e-ink device based on the Inkplate platform. It's a lovely little gadget — compact enough for a coat pocket, with a crisp e-paper display that's easy on the eyes.

CrossPoint e-reader at a café

The device runs a web server when connected to WiFi, exposing a simple REST API for file management. This got me thinking: what if I could pipe web articles directly to it?

The Problem

I read a lot of long-form articles — essays, research pieces, the occasional Marxist theory. Reading these on a phone or laptop isn't ideal. The CrossPoint is perfect for this, but manually converting articles to EPUB and transferring them is tedious.

The Solution

I built a skill for my AI assistant that automates the entire flow:

  1. Extract — Uses Mozilla's Readability (the engine behind Firefox Reader Mode) to pull clean article content from any URL
  2. Convert — Transforms the HTML to EPUB via Pandoc
  3. Discover — Finds the CrossPoint on my local network using mDNS (crosspoint.local)
  4. Upload — Pushes the EPUB directly to the device
send-article.sh "https://marxists.org/..." "On Practice - Mao"

That's it. The article appears on my e-reader within seconds.

Offline Queue

The skill also handles the device being offline. Articles are saved to a directory served by my home media server. When I'm ready to sync, I just run:

send-article.sh --sync

It discovers the device and uploads everything in the queue.

The Full Script

Here's the complete script — about 250 lines of bash:

#!/bin/bash
# send-article.sh - Extract article from URL, convert to EPUB, queue/upload to CrossPoint e-reader
set -euo pipefail

EPUB_DIR="/path/to/your/epubs"
QUEUE_FILE="$EPUB_DIR/.queue.json"
CACHE_FILE="$HOME/.crosspoint_ip"
PUBLIC_URL="https://your-server.com/books"

mkdir -p "$EPUB_DIR"

# Initialize queue if needed
if [ ! -f "$QUEUE_FILE" ]; then
    echo '{"pending":[],"synced":[]}' > "$QUEUE_FILE"
fi

usage() {
    echo "Usage: $0 <url> <title> [device_ip]" >&2
    echo "       $0 --sync              Sync pending books to device" >&2
    echo "       $0 --list              List queue status" >&2
    echo "       $0 --clear-synced      Clear synced history" >&2
    exit 1
}

# Check if device is reachable
check_device() {
    local ip="$1"
    local timeout="${2:-1}"
    curl -s --connect-timeout "$timeout" "http://$ip/api/status" 2>/dev/null | grep -q '"version"'
}

# Resolve mDNS hostname to IP
resolve_mdns() {
    local tmpfile=$(mktemp)
    dns-sd -G v4 crosspoint.local > "$tmpfile" 2>/dev/null &
    local pid=$!
    sleep 0.5
    kill $pid 2>/dev/null || true
    local ip=$(grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' "$tmpfile" | head -1)
    rm -f "$tmpfile"
    echo "$ip"
}

# Discover device IP
discover_device() {
    # Try mDNS first (DHCP-proof, instant)
    local mdns_ip=$(resolve_mdns)
    if [ -n "$mdns_ip" ] && check_device "$mdns_ip" 1; then
        echo "$mdns_ip" > "$CACHE_FILE"
        echo "$mdns_ip"
        return 0
    fi
    
    # Fallback: Try cached IP
    if [ -f "$CACHE_FILE" ]; then
        local cached_ip=$(cat "$CACHE_FILE")
        if check_device "$cached_ip" 1; then
            echo "$cached_ip"
            return 0
        fi
    fi
    
    # Last resort: Scan local network (0.3s timeout per IP)
    local local_ip=$(/sbin/ifconfig en0 2>/dev/null | grep "inet " | awk '{print $2}')
    if [ -n "$local_ip" ]; then
        local prefix=$(echo "$local_ip" | cut -d. -f1-3)
        for i in $(seq 1 254); do
            local ip="$prefix.$i"
            if check_device "$ip" 0.3; then
                echo "$ip" > "$CACHE_FILE"
                echo "$ip"
                return 0
            fi
        done
    fi
    
    return 1
}

# Upload a single file to device
upload_to_device() {
    local file="$1"
    local ip="$2"
    local filename=$(basename "$file")
    
    local result=$(curl -s -X POST -F "file=@${file}" "http://${ip}/upload?path=/" 2>&1)
    if echo "$result" | grep -qi "success\|uploaded"; then
        return 0
    fi
    return 1
}

# List queue status
list_queue() {
    echo "=== E-Reader Queue ===" >&2
    echo "" >&2
    echo "📂 EPUB Directory: $EPUB_DIR" >&2
    echo "🌐 Public URL: $PUBLIC_URL" >&2
    echo "" >&2
    
    local pending=$(jq -r '.pending | length' "$QUEUE_FILE")
    local synced=$(jq -r '.synced | length' "$QUEUE_FILE")
    
    echo "📋 Pending: $pending" >&2
    if [ "$pending" -gt 0 ]; then
        jq -r '.pending[] | "   - \(.title) (\(.file))"' "$QUEUE_FILE" >&2
    fi
    
    echo "" >&2
    echo "✅ Synced: $synced" >&2
    if [ "$synced" -gt 0 ]; then
        jq -r '.synced[-5:] | reverse | .[] | "   - \(.title) (\(.synced | split("T")[0]))"' "$QUEUE_FILE" >&2
    fi
    
    # Check device status (quick check only)
    echo "" >&2
    if [ -f "$CACHE_FILE" ]; then
        local cached_ip=$(cat "$CACHE_FILE")
        if check_device "$cached_ip" 1; then
            echo "📱 Device: Online at $cached_ip" >&2
        else
            echo "📱 Device: Offline (last seen at $cached_ip)" >&2
        fi
    else
        echo "📱 Device: Unknown (no cached IP)" >&2
    fi
}

# Sync all pending to device
sync_queue() {
    echo "=== Syncing to E-Reader ===" >&2
    
    local device_ip="${1:-}"
    if [ -z "$device_ip" ]; then
        echo "Discovering device..." >&2
        if ! device_ip=$(discover_device); then
            echo "ERROR: Device not found. Books remain queued." >&2
            exit 1
        fi
    fi
    echo "Device found at: $device_ip" >&2
    
    local pending=$(jq -r '.pending | length' "$QUEUE_FILE")
    if [ "$pending" -eq 0 ]; then
        echo "No pending books to sync." >&2
        exit 0
    fi
    
    local synced_count=0
    local failed_count=0
    
    # Process each pending item
    while IFS= read -r item; do
        local file=$(echo "$item" | jq -r '.file')
        local title=$(echo "$item" | jq -r '.title')
        local filepath="$EPUB_DIR/$file"
        
        if [ ! -f "$filepath" ]; then
            echo "  ⚠ Missing: $file" >&2
            continue
        fi
        
        echo "  Uploading: $title" >&2
        if upload_to_device "$filepath" "$device_ip"; then
            echo "    ✓ Done" >&2
            local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
            jq --arg file "$file" --arg title "$title" --arg now "$now" \
                '.pending = [.pending[] | select(.file != $file)] | .synced += [{"file": $file, "title": $title, "synced": $now}]' \
                "$QUEUE_FILE" > "$QUEUE_FILE.tmp" && mv "$QUEUE_FILE.tmp" "$QUEUE_FILE"
            ((synced_count++)) || true
        else
            echo "    ✗ Failed" >&2
            ((failed_count++)) || true
        fi
    done < <(jq -c '.pending[]' "$QUEUE_FILE")
    
    echo "" >&2
    echo "Synced: $synced_count, Failed: $failed_count" >&2
}

# Add to queue
add_to_queue() {
    local file="$1"
    local title="$2"
    local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    
    jq --arg file "$file" --arg title "$title" --arg now "$now" \
        '.pending += [{"file": $file, "title": $title, "added": $now}]' \
        "$QUEUE_FILE" > "$QUEUE_FILE.tmp" && mv "$QUEUE_FILE.tmp" "$QUEUE_FILE"
}

# Mark as synced
mark_synced() {
    local file="$1"
    local title="$2"
    local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    
    jq --arg file "$file" --arg title "$title" --arg now "$now" \
        '.pending = [.pending[] | select(.file != $file)] | .synced += [{"file": $file, "title": $title, "synced": $now}]' \
        "$QUEUE_FILE" > "$QUEUE_FILE.tmp" && mv "$QUEUE_FILE.tmp" "$QUEUE_FILE"
}

# Handle commands
case "${1:-}" in
    --list|-l)
        list_queue
        exit 0
        ;;
    --sync|-s)
        sync_queue "${2:-}"
        exit 0
        ;;
    --clear-synced)
        jq '.synced = []' "$QUEUE_FILE" > "$QUEUE_FILE.tmp" && mv "$QUEUE_FILE.tmp" "$QUEUE_FILE"
        echo "Cleared synced history." >&2
        exit 0
        ;;
    --help|-h|"")
        usage
        ;;
    --*)
        echo "Unknown option: $1" >&2
        usage
        ;;
esac

# Main: extract article and queue/upload
URL="$1"
TITLE="${2:-article}"
DEVICE_IP="${3:-}"

SAFE_TITLE=$(echo "$TITLE" | tr -cd '[:alnum:] ._-' | tr ' ' '_' | head -c 100)
[ -z "$SAFE_TITLE" ] && SAFE_TITLE="article_$(date +%s)"
EPUB_FILE="$EPUB_DIR/${SAFE_TITLE}.epub"

echo "=== Send to E-Reader ===" >&2

# Step 1: Extract article content
echo "Extracting article from: $URL" >&2

TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT

if command -v readable &>/dev/null; then
    readable --low-confidence force "$URL" > "$TMPDIR/article.html" 2>/dev/null
elif command -v rdrview &>/dev/null; then
    rdrview -H "$URL" > "$TMPDIR/article.html" 2>/dev/null
else
    echo "ERROR: readable not found. Install with: npm install -g readability-cli" >&2
    exit 2
fi

if [ ! -s "$TMPDIR/article.html" ]; then
    echo "ERROR: Failed to extract article content" >&2
    exit 3
fi
echo "  ✓ Article extracted" >&2

# Step 2: Convert to EPUB
echo "Converting to EPUB..." >&2

if ! command -v pandoc &>/dev/null; then
    echo "ERROR: pandoc not found. Install with: brew install pandoc" >&2
    exit 2
fi

pandoc -f html -t epub \
    --metadata title="$TITLE" \
    -o "$EPUB_FILE" \
    "$TMPDIR/article.html"

if [ ! -s "$EPUB_FILE" ]; then
    echo "ERROR: Failed to create EPUB" >&2
    exit 4
fi
EPUB_SIZE=$(stat -f%z "$EPUB_FILE" 2>/dev/null || stat -c%s "$EPUB_FILE" 2>/dev/null)
echo "  ✓ EPUB saved: ${SAFE_TITLE}.epub (${EPUB_SIZE} bytes)" >&2

# Step 3: Try to upload, or queue if offline
echo "Checking device..." >&2

if [ -n "$DEVICE_IP" ]; then
    if ! check_device "$DEVICE_IP"; then
        echo "  Device at $DEVICE_IP not responding. Queuing." >&2
        add_to_queue "${SAFE_TITLE}.epub" "$TITLE"
        exit 0
    fi
elif DEVICE_IP=$(discover_device 2>/dev/null); then
    echo "  ✓ Found device at $DEVICE_IP" >&2
else
    echo "  Device offline. Queuing." >&2
    add_to_queue "${SAFE_TITLE}.epub" "$TITLE"
    echo "Run '$0 --sync' when device is available." >&2
    exit 0
fi

# Step 4: Upload
echo "Uploading to device..." >&2
if upload_to_device "$EPUB_FILE" "$DEVICE_IP"; then
    mark_synced "${SAFE_TITLE}.epub" "$TITLE"
    echo "  ✓ Upload complete!" >&2
    echo "Article '$TITLE' is now on your e-reader." >&2
else
    add_to_queue "${SAFE_TITLE}.epub" "$TITLE"
    echo "  Upload failed. Queued for retry." >&2
    exit 1
fi

Dependencies

You'll need these tools installed:

# Article extraction (Firefox Reader Mode)
npm install -g readability-cli

# Document conversion
brew install pandoc

# JSON processing (usually pre-installed)
brew install jq

CrossPoint API

The device exposes these endpoints:

  • GET /api/status — Device info and connectivity
  • GET /api/files — Directory listing
  • POST /upload — Multipart file upload

Discovery uses mDNS, so DHCP IP changes don't break anything. The device advertises itself as crosspoint.local.

Why This Matters

There's something satisfying about building personal infrastructure that just works. No cloud services, no subscriptions, no tracking. Just a URL in, reading material out.

The CrossPoint itself embodies this ethos — open hardware, open firmware, repairable and hackable. It's the kind of device that rewards tinkering.

Now if you'll excuse me, I have some theory to catch up on.

Comments 0

No comments yet. Be the first to comment!