# Piping the Web to My E-Reader 2026-02-12 I've been using the [CrossPoint Reader](https://github.com/crosspoint-reader/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é](https://link.plex.uno/downloads/blog/crosspoint-ereader-cafe.jpg) 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 ```bash 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: ```bash 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: ```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 [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: ```bash # 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.