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.

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:
- Extract — Uses Mozilla's Readability (the engine behind Firefox Reader Mode) to pull clean article content from any URL
- Convert — Transforms the HTML to EPUB via Pandoc
- Discover — Finds the CrossPoint on my local network using mDNS (
crosspoint.local) - 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 connectivityGET /api/files— Directory listingPOST /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.