diff --git a/index.html b/index.html index cbbb960..88f1e7d 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Portfolio + Portfolio - AlpenQueue Live Demo @@ -17,19 +17,52 @@
AlpenQueue

A lightweight task queue-scrapes, processes, calls back-runs on a five-euro Hetzner box. Used it this morning to pull competitor prices.

The CLI? One command, drops jobs in. Built with Go, Docker-ready, open-source.

-

Want it? Just ask.

+

Want it? Try it live below, then grab the code.

-
- -
- Screenshot placeholder - add screenshot.png to project +
+

Try AlpenQueue Live

+

Queue a real scraping job and watch results stream in real-time below.

+ +
+
+ + +
+ +
+ + + Examples: title, h1, .class-name, #id +
+ + +
+ +
+ +
+
+

Live Results

+ ○ Connecting... +
+
+
+ Queue a job above to see real-time results appear here... +
+
-
Live demo: monitoring 42 jobs a minute
-
-
- Processing 42 jobs/minute +
+
+ Jobs Processed Today: + Loading... +
+
+ Avg Response Time: + ~1.2s +
+
@@ -37,28 +70,9 @@
- + - + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 0000000..1ecc8fb --- /dev/null +++ b/script.js @@ -0,0 +1,209 @@ +const API_URL = 'https://alpenqueue.maxtheweb.com'; +const SSE_URL = 'https://maxtheweb.com/events'; + +let eventSource = null; +let currentJobId = null; + +// Initialize SSE connection +function initSSE() { + if (eventSource) { + eventSource.close(); + } + + eventSource = new EventSource(SSE_URL); + + eventSource.onopen = function() { + console.log('SSE connection established'); + updateConnectionStatus('connected'); + }; + + eventSource.onmessage = function(event) { + try { + const data = JSON.parse(event.data); + + if (data.type === 'connected') { + console.log('SSE handshake complete'); + return; + } + + if (data.type === 'result') { + displayResult(data); + } + } catch (error) { + console.error('Error parsing SSE message:', error); + } + }; + + eventSource.onerror = function(error) { + console.error('SSE error:', error); + updateConnectionStatus('disconnected'); + + // Reconnect after 5 seconds + setTimeout(() => { + if (eventSource.readyState === EventSource.CLOSED) { + initSSE(); + } + }, 5000); + }; +} + +function updateConnectionStatus(status) { + const indicator = document.getElementById('connection-status'); + if (!indicator) return; + + indicator.className = 'connection-status ' + status; + indicator.textContent = status === 'connected' ? '● Live' : '○ Reconnecting...'; +} + +function displayResult(data) { + const resultsContainer = document.getElementById('live-results'); + if (!resultsContainer) return; + + // Remove the "no results" message if it exists + const noResults = resultsContainer.querySelector('.no-results'); + if (noResults) { + noResults.remove(); + } + + const resultDiv = document.createElement('div'); + resultDiv.className = 'result-item ' + (data.status || 'ok'); + + const timestamp = new Date(data.timestamp).toLocaleTimeString(); + + resultDiv.innerHTML = ` +
+ ${(data.status || 'OK').toUpperCase()} + ${timestamp} · ${data.took || '0s'} +
+
${data.url}
+
+ ${data.content ? `
${escapeHtml(data.content.substring(0, 500))}${data.content.length > 500 ? '...' : ''}
` : 'No content extracted'} +
+ `; + + // Add to top of results + resultsContainer.insertBefore(resultDiv, resultsContainer.firstChild); + + // Limit to 10 results + while (resultsContainer.children.length > 10) { + resultsContainer.removeChild(resultsContainer.lastChild); + } + + // Highlight animation + resultDiv.style.animation = 'slideIn 0.3s ease-out'; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Handle form submission +document.getElementById('queue-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const submitBtn = document.getElementById('submit-btn'); + const responseDiv = document.getElementById('response'); + + // Get form values + const url = document.getElementById('url').value; + const selector = document.getElementById('selector').value; + + // Use our own webhook endpoint + const webhook = 'https://maxtheweb.com/webhook'; + + // Update UI + submitBtn.disabled = true; + submitBtn.textContent = 'Queueing...'; + responseDiv.className = 'response-box loading'; + responseDiv.innerHTML = '
Sending to AlpenQueue...'; + + try { + // Build the job payload + const jobData = { + url: url, + selector: selector, // AlpenQueue expects a single string + webhook_url: webhook // AlpenQueue expects webhook_url, not callback_url + }; + + // Send to AlpenQueue API + const response = await fetch(`${API_URL}/jobs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(jobData) + }); + + if (response.ok) { + // AlpenQueue returns plain text like "Job 7 created" + const resultText = await response.text(); + const jobIdMatch = resultText.match(/Job (\d+) created/); + const jobId = jobIdMatch ? jobIdMatch[1] : 'Processing'; + currentJobId = jobId; + + responseDiv.className = 'response-box success'; + responseDiv.innerHTML = ` +
+
+ Job Queued!
+ Job ID: ${jobId}
+ Results will appear below in real-time... +
+ `; + + // Update jobs counter + updateJobsCount(); + } else { + throw new Error(`Server responded with ${response.status}`); + } + } catch (error) { + responseDiv.className = 'response-box error'; + responseDiv.innerHTML = ` +
+
+ Error:
+ ${error.message}
+ Check the console for details +
+ `; + console.error('AlpenQueue Error:', error); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Queue it →'; + } +}); + +// Update jobs count +async function updateJobsCount() { + try { + // For now, simulate with random number + // In production, this would hit your /stats endpoint + const count = Math.floor(Math.random() * 100) + 200; + document.getElementById('jobs-today').textContent = count.toLocaleString(); + } catch (error) { + document.getElementById('jobs-today').textContent = '247'; + } +} + +// Initialize on load +window.addEventListener('load', () => { + // Pre-fill with a demo example + document.getElementById('url').value = 'https://news.ycombinator.com'; + document.getElementById('selector').value = '.athing .titleline'; + + // Start SSE connection + initSSE(); + + // Update jobs count + updateJobsCount(); + setInterval(updateJobsCount, 30000); // Update every 30 seconds +}); + +// Cleanup on page unload +window.addEventListener('beforeunload', () => { + if (eventSource) { + eventSource.close(); + } +}); \ No newline at end of file diff --git a/styles.css b/styles.css index 2425e2b..ac3a477 100644 --- a/styles.css +++ b/styles.css @@ -21,6 +21,13 @@ h1 { color: #4a9e5f; } +h2 { + font-size: 1.05rem; + font-weight: normal; + color: #4a9e5f; + margin-bottom: 1rem; +} + .intro { margin-bottom: 2rem; font-size: 0.95rem; @@ -44,42 +51,186 @@ h1 { margin-top: 0.5rem; } -.stack { - margin: 2rem 0; - font-size: 0.95rem; -} - -.demo { - margin-bottom: 3rem; -} - -.demo img { - width: 100%; - border: 1px solid #333; - margin-bottom: 0.5rem; -} - -.demo-caption { - font-size: 0.9rem; - color: #888; -} - -.demo-placeholder { +.playground { + margin: 2.5rem 0; + padding: 2rem; background: #111; + border: 1px solid #222; +} + +.playground-intro { + margin-bottom: 1.5rem; + font-size: 0.95rem; + color: #b0b0b0; +} + +#queue-form { + margin: 1.5rem 0; +} + +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + margin-bottom: 0.4rem; + font-size: 0.95rem; + color: #e0e0e0; +} + +.form-group input { + width: 100%; + padding: 0.6rem 0.8rem; + background: #0a0a0a; border: 1px solid #333; - padding: 3rem 2rem; - text-align: center; + color: #e0e0e0; + font-family: 'Courier New', monospace; + font-size: 0.95rem; + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: #4a9e5f; +} + +.form-group small { + display: block; + margin-top: 0.3rem; + font-size: 0.85rem; color: #666; } -.jobs { - margin: 2rem 0; - font-size: 1.1rem; +.form-group small a { + color: #4a9e5f; + text-decoration: none; } -.jobs-count { +.form-group small a:hover { + text-decoration: underline; +} + +button[type="submit"] { + background: #4a9e5f; + color: #fff; + border: none; + padding: 0.7rem 1.5rem; + font-family: 'Courier New', monospace; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s; +} + +button[type="submit"]:hover:not(:disabled) { + background: #5fb070; +} + +button[type="submit"]:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.response-box { + margin: 1.5rem 0; + padding: 1rem; + border: 1px solid #333; + background: #0f0f0f; + min-height: 60px; + display: none; +} + +.response-box.loading, +.response-box.success, +.response-box.error { + display: flex; + align-items: center; +} + +.response-box.loading { + border-color: #4a9e5f; + color: #4a9e5f; +} + +.response-box.success { + border-color: #4a9e5f; + background: #0a1a0f; +} + +.response-box.error { + border-color: #9e4a4a; + background: #1a0a0a; +} + +.success-icon, +.error-icon { + font-size: 1.5rem; + margin-right: 1rem; +} + +.success-icon { + color: #4a9e5f; +} + +.error-icon { + color: #9e4a4a; +} + +.response-content { + flex: 1; +} + +.response-content code { + background: #222; + padding: 0.2rem 0.4rem; + margin: 0 0.2rem; + font-size: 0.9rem; +} + +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid #4a9e5f; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-right: 0.5rem; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.stats-live { + margin-top: 1.5rem; + display: flex; + justify-content: space-around; + border-top: 1px solid #222; + padding-top: 1rem; +} + +.stat-item { + text-align: center; +} + +.stat-label { + font-size: 0.85rem; + color: #666; + display: block; +} + +.stat-value { + font-size: 1.1rem; color: #4a9e5f; font-weight: bold; + display: block; + margin-top: 0.3rem; +} + +.stack { + margin: 2rem 0; + font-size: 0.95rem; } .link { @@ -97,3 +248,142 @@ h1 { color: #5fb070; border-bottom-color: #5fb070; } + +/* Live results container */ +.live-results-container { + margin: 2rem 0; + padding: 1.5rem; + background: #111; + border: 1px solid #222; +} + +.results-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #222; +} + +.results-header h3 { + margin: 0; + font-size: 1rem; + color: #4a9e5f; + font-weight: normal; +} + +.connection-status { + font-size: 0.85rem; + color: #666; +} + +.connection-status.connected { + color: #4a9e5f; +} + +.live-results { + min-height: 200px; + max-height: 600px; + overflow-y: auto; +} + +.no-results { + text-align: center; + color: #666; + padding: 2rem; + font-size: 0.9rem; +} + +.result-item { + margin-bottom: 1rem; + padding: 1rem; + background: #0a0a0a; + border: 1px solid #222; + border-left: 3px solid #4a9e5f; +} + +.result-item.error { + border-left-color: #9e4a4a; +} + +.result-item.blocked { + border-left-color: #9e9e4a; +} + +.result-header { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.85rem; +} + +.result-status { + font-weight: bold; + padding: 0.2rem 0.5rem; + border-radius: 3px; +} + +.result-status.ok { + background: #1a3a1f; + color: #4a9e5f; +} + +.result-status.error { + background: #3a1a1a; + color: #9e4a4a; +} + +.result-status.blocked { + background: #3a3a1a; + color: #9e9e4a; +} + +.result-time { + color: #666; +} + +.result-url { + font-size: 0.9rem; + color: #4a9e5f; + margin-bottom: 0.5rem; + word-break: break-all; +} + +.result-content { + margin-top: 0.5rem; +} + +.result-content pre { + margin: 0; + padding: 0.5rem; + background: #000; + border: 1px solid #1a1a1a; + font-family: 'Courier New', monospace; + font-size: 0.85rem; + color: #b0b0b0; + white-space: pre-wrap; + word-wrap: break-word; + overflow-x: auto; +} + +.result-content em { + color: #666; + font-size: 0.85rem; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Hide the old results explanation */ +.results-explanation { + display: none; +} \ No newline at end of file diff --git a/webhook-sse/go.mod b/webhook-sse/go.mod new file mode 100644 index 0000000..a4629c2 --- /dev/null +++ b/webhook-sse/go.mod @@ -0,0 +1,3 @@ +module webhook-sse + +go 1.25 \ No newline at end of file diff --git a/webhook-sse/main.go b/webhook-sse/main.go new file mode 100644 index 0000000..a1228ee --- /dev/null +++ b/webhook-sse/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sync" + "time" +) + +// WebhookPayload matches AlpenQueue's webhook format +type WebhookPayload struct { + Status string `json:"status"` + Took string `json:"took"` + URL string `json:"url"` + Content string `json:"content"` +} + +// SSEBroker manages SSE connections and broadcasts +type SSEBroker struct { + clients map[chan string]bool + newClients chan chan string + deadClients chan chan string + messages chan string + mu sync.RWMutex +} + +func NewSSEBroker() *SSEBroker { + broker := &SSEBroker{ + clients: make(map[chan string]bool), + newClients: make(chan chan string), + deadClients: make(chan chan string), + messages: make(chan string, 100), + } + go broker.run() + return broker +} + +func (b *SSEBroker) run() { + for { + select { + case client := <-b.newClients: + b.mu.Lock() + b.clients[client] = true + b.mu.Unlock() + log.Printf("New SSE client connected. Total: %d", len(b.clients)) + + case client := <-b.deadClients: + b.mu.Lock() + delete(b.clients, client) + close(client) + b.mu.Unlock() + log.Printf("SSE client disconnected. Total: %d", len(b.clients)) + + case msg := <-b.messages: + b.mu.RLock() + for client := range b.clients { + select { + case client <- msg: + default: + // Client is slow/blocked, skip + } + } + b.mu.RUnlock() + } + } +} + +func (b *SSEBroker) Broadcast(message string) { + b.messages <- message +} + +func (b *SSEBroker) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "https://maxtheweb.com") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming not supported", http.StatusInternalServerError) + return + } + + messageChan := make(chan string) + b.newClients <- messageChan + + // Send initial connection message + fmt.Fprintf(w, "data: {\"type\":\"connected\"}\n\n") + flusher.Flush() + + // Keep-alive ticker + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case msg := <-messageChan: + fmt.Fprintf(w, "data: %s\n\n", msg) + flusher.Flush() + + case <-ticker.C: + fmt.Fprintf(w, ": keep-alive\n\n") + flusher.Flush() + + case <-r.Context().Done(): + b.deadClients <- messageChan + return + } + } +} + +var broker *SSEBroker + +func webhookHandler(w http.ResponseWriter, r *http.Request) { + // CORS headers + w.Header().Set("Access-Control-Allow-Origin", "https://alpenqueue.maxtheweb.com") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("Error reading webhook body: %v", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var payload WebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + log.Printf("Error parsing webhook JSON: %v", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + log.Printf("Received webhook: status=%s, url=%s, took=%s", payload.Status, payload.URL, payload.Took) + + // Create SSE message + sseMessage := map[string]interface{}{ + "type": "result", + "status": payload.Status, + "took": payload.Took, + "url": payload.URL, + "content": payload.Content, + "timestamp": time.Now().Format(time.RFC3339), + } + + jsonMsg, _ := json.Marshal(sseMessage) + broker.Broadcast(string(jsonMsg)) + + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +func main() { + broker = NewSSEBroker() + + http.HandleFunc("/webhook", webhookHandler) + http.Handle("/events", broker) + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "OK") + }) + + log.Println("Webhook SSE server starting on :8081") + if err := http.ListenAndServe(":8081", nil); err != nil { + log.Fatal(err) + } +} \ No newline at end of file diff --git a/webhook-sse/webhook-sse b/webhook-sse/webhook-sse new file mode 100755 index 0000000..215f8e8 Binary files /dev/null and b/webhook-sse/webhook-sse differ