simplify
This commit is contained in:
@@ -3,10 +3,7 @@
|
|||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": [
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
"prettier-plugin-svelte",
|
|
||||||
"prettier-plugin-tailwindcss"
|
|
||||||
],
|
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": "*.svelte",
|
"files": "*.svelte",
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -5,6 +5,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
|
|||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
### Core Functionality
|
### Core Functionality
|
||||||
|
|
||||||
- **Async Queue Processing**: Fire-and-forget recipe extraction with background processing
|
- **Async Queue Processing**: Fire-and-forget recipe extraction with background processing
|
||||||
- **Real-time Updates**: Server-Sent Events for live progress tracking
|
- **Real-time Updates**: Server-Sent Events for live progress tracking
|
||||||
- **Push Notifications**: Background notifications when recipes complete
|
- **Push Notifications**: Background notifications when recipes complete
|
||||||
@@ -13,6 +14,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
|
|||||||
- **PWA Support**: Installable Progressive Web App with offline capabilities
|
- **PWA Support**: Installable Progressive Web App with offline capabilities
|
||||||
|
|
||||||
### User Experience
|
### User Experience
|
||||||
|
|
||||||
- **Queue Dashboard**: Monitor all recipe extractions in real-time
|
- **Queue Dashboard**: Monitor all recipe extractions in real-time
|
||||||
- **Share Integration**: Browser share target for easy URL submission
|
- **Share Integration**: Browser share target for easy URL submission
|
||||||
- **Responsive Design**: Works on desktop, tablet, and mobile
|
- **Responsive Design**: Works on desktop, tablet, and mobile
|
||||||
@@ -20,6 +22,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
|
|||||||
- **Progress Tracking**: Visual progress through extraction phases
|
- **Progress Tracking**: Visual progress through extraction phases
|
||||||
|
|
||||||
### Technical Architecture
|
### Technical Architecture
|
||||||
|
|
||||||
- **SvelteKit Frontend**: Modern reactive UI with TypeScript
|
- **SvelteKit Frontend**: Modern reactive UI with TypeScript
|
||||||
- **Hexagonal Architecture**: Clean separation of concerns
|
- **Hexagonal Architecture**: Clean separation of concerns
|
||||||
- **In-Memory Queue**: High-performance processing with configurable concurrency
|
- **In-Memory Queue**: High-performance processing with configurable concurrency
|
||||||
@@ -29,6 +32,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
|
|||||||
## 📋 API Endpoints
|
## 📋 API Endpoints
|
||||||
|
|
||||||
### Queue Management
|
### Queue Management
|
||||||
|
|
||||||
- `POST /api/queue` - Enqueue Instagram URL for processing
|
- `POST /api/queue` - Enqueue Instagram URL for processing
|
||||||
- `GET /api/queue` - List queue items with filtering and pagination
|
- `GET /api/queue` - List queue items with filtering and pagination
|
||||||
- `GET /api/queue/{id}` - Get specific queue item details
|
- `GET /api/queue/{id}` - Get specific queue item details
|
||||||
@@ -36,17 +40,20 @@ A modern web application that extracts recipes from Instagram posts and saves th
|
|||||||
- `GET /api/queue/stream` - Server-Sent Events for real-time updates
|
- `GET /api/queue/stream` - Server-Sent Events for real-time updates
|
||||||
|
|
||||||
### Push Notifications
|
### Push Notifications
|
||||||
|
|
||||||
- `POST /api/notifications/subscribe` - Subscribe to push notifications
|
- `POST /api/notifications/subscribe` - Subscribe to push notifications
|
||||||
- `DELETE /api/notifications/subscribe` - Unsubscribe from notifications
|
- `DELETE /api/notifications/subscribe` - Unsubscribe from notifications
|
||||||
- `GET /api/notifications/vapid-key` - Get VAPID public key
|
- `GET /api/notifications/vapid-key` - Get VAPID public key
|
||||||
|
|
||||||
### Legacy Endpoints (Deprecated)
|
### Legacy Endpoints (Deprecated)
|
||||||
|
|
||||||
- ~~`POST /api/extract`~~ - Use `/api/queue` instead
|
- ~~`POST /api/extract`~~ - Use `/api/queue` instead
|
||||||
- ~~`GET /api/extract-stream`~~ - Use `/api/queue/stream` instead
|
- ~~`GET /api/extract-stream`~~ - Use `/api/queue/stream` instead
|
||||||
|
|
||||||
## 🛠 Development Setup
|
## 🛠 Development Setup
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- npm or pnpm
|
- npm or pnpm
|
||||||
- Tandoor Recipe Manager instance (optional)
|
- Tandoor Recipe Manager instance (optional)
|
||||||
@@ -79,6 +86,7 @@ open https://localhost:5173
|
|||||||
```
|
```
|
||||||
|
|
||||||
The app runs on HTTPS by default for:
|
The app runs on HTTPS by default for:
|
||||||
|
|
||||||
- Service worker support (required for PWA)
|
- Service worker support (required for PWA)
|
||||||
- Push notifications
|
- Push notifications
|
||||||
- Browser share target API
|
- Browser share target API
|
||||||
@@ -89,6 +97,7 @@ The app runs on HTTPS by default for:
|
|||||||
The application uses HTTPS in development with SSL certificates signed by an external Caddy CA container. The current certificate is valid until **December 20, 2035** (10 years).
|
The application uses HTTPS in development with SSL certificates signed by an external Caddy CA container. The current certificate is valid until **December 20, 2035** (10 years).
|
||||||
|
|
||||||
**Certificate Information:**
|
**Certificate Information:**
|
||||||
|
|
||||||
- Location: `.ssl/` directory
|
- Location: `.ssl/` directory
|
||||||
- CA Certificate: `.ssl/root.crt` (already trusted on the system)
|
- CA Certificate: `.ssl/root.crt` (already trusted on the system)
|
||||||
- Server Certificate: `.ssl/localhost.crt`
|
- Server Certificate: `.ssl/localhost.crt`
|
||||||
@@ -97,18 +106,21 @@ The application uses HTTPS in development with SSL certificates signed by an ext
|
|||||||
Since the Caddy CA is already trusted on the system, the certificate should work without additional trust steps. If you encounter browser warnings:
|
Since the Caddy CA is already trusted on the system, the certificate should work without additional trust steps. If you encounter browser warnings:
|
||||||
|
|
||||||
**Linux (Ubuntu/Debian):**
|
**Linux (Ubuntu/Debian):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp .ssl/root.crt /usr/local/share/ca-certificates/caddy-local.crt
|
sudo cp .ssl/root.crt /usr/local/share/ca-certificates/caddy-local.crt
|
||||||
sudo update-ca-certificates
|
sudo update-ca-certificates
|
||||||
```
|
```
|
||||||
|
|
||||||
**Chrome/Chromium:**
|
**Chrome/Chromium:**
|
||||||
|
|
||||||
1. Go to `chrome://settings/certificates`
|
1. Go to `chrome://settings/certificates`
|
||||||
2. Click "Authorities" → "Import"
|
2. Click "Authorities" → "Import"
|
||||||
3. Select `.ssl/root.crt`
|
3. Select `.ssl/root.crt`
|
||||||
4. Check "Trust this certificate for identifying websites"
|
4. Check "Trust this certificate for identifying websites"
|
||||||
|
|
||||||
**Checking Certificate Expiration:**
|
**Checking Certificate Expiration:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openssl x509 -enddate -noout -in .ssl/localhost.crt
|
openssl x509 -enddate -noout -in .ssl/localhost.crt
|
||||||
```
|
```
|
||||||
@@ -220,6 +232,7 @@ To enable web push notifications:
|
|||||||
## 🏗 Architecture Overview
|
## 🏗 Architecture Overview
|
||||||
|
|
||||||
### Queue System
|
### Queue System
|
||||||
|
|
||||||
```
|
```
|
||||||
User submits URL → Queue Manager → Queue Processor
|
User submits URL → Queue Manager → Queue Processor
|
||||||
↓
|
↓
|
||||||
@@ -257,6 +270,7 @@ npm run test:watch
|
|||||||
```
|
```
|
||||||
|
|
||||||
Test Coverage:
|
Test Coverage:
|
||||||
|
|
||||||
- **138 total tests** covering all major components
|
- **138 total tests** covering all major components
|
||||||
- Queue Manager: 28 tests
|
- Queue Manager: 28 tests
|
||||||
- Queue Processor: 5 integration tests
|
- Queue Processor: 5 integration tests
|
||||||
@@ -279,11 +293,13 @@ npm run preview
|
|||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
The app is built as a Node.js application with the following outputs:
|
The app is built as a Node.js application with the following outputs:
|
||||||
|
|
||||||
- `/.svelte-kit/output/server/` - Server bundle
|
- `/.svelte-kit/output/server/` - Server bundle
|
||||||
- `/.svelte-kit/output/client/` - Static assets
|
- `/.svelte-kit/output/client/` - Static assets
|
||||||
- `/build/` - Adapter output
|
- `/build/` - Adapter output
|
||||||
|
|
||||||
Deploy the server bundle with:
|
Deploy the server bundle with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node build/index.js
|
node build/index.js
|
||||||
```
|
```
|
||||||
@@ -307,6 +323,7 @@ CMD ["node", "build"]
|
|||||||
The app was migrated from a synchronous extraction system to an async queue-based system:
|
The app was migrated from a synchronous extraction system to an async queue-based system:
|
||||||
|
|
||||||
**Before (Synchronous)**:
|
**Before (Synchronous)**:
|
||||||
|
|
||||||
- User waited for entire extraction process to complete
|
- User waited for entire extraction process to complete
|
||||||
- No progress tracking during processing
|
- No progress tracking during processing
|
||||||
- No retry capability for failures
|
- No retry capability for failures
|
||||||
@@ -314,6 +331,7 @@ The app was migrated from a synchronous extraction system to an async queue-base
|
|||||||
- Limited error handling
|
- Limited error handling
|
||||||
|
|
||||||
**After (Async Queue)**:
|
**After (Async Queue)**:
|
||||||
|
|
||||||
- Fire-and-forget: submit URL and redirect immediately
|
- Fire-and-forget: submit URL and redirect immediately
|
||||||
- Real-time progress tracking via SSE
|
- Real-time progress tracking via SSE
|
||||||
- Comprehensive retry system for failures
|
- Comprehensive retry system for failures
|
||||||
@@ -324,12 +342,14 @@ The app was migrated from a synchronous extraction system to an async queue-base
|
|||||||
### API Migration
|
### API Migration
|
||||||
|
|
||||||
**Old Synchronous Endpoints** (Deprecated):
|
**Old Synchronous Endpoints** (Deprecated):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/extract # Submit URL and wait for completion
|
POST /api/extract # Submit URL and wait for completion
|
||||||
GET /api/extract-stream # Long-polling for progress
|
GET /api/extract-stream # Long-polling for progress
|
||||||
```
|
```
|
||||||
|
|
||||||
**New Queue Endpoints**:
|
**New Queue Endpoints**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/queue # Submit URL, get queue ID immediately
|
POST /api/queue # Submit URL, get queue ID immediately
|
||||||
GET /api/queue # List all queue items
|
GET /api/queue # List all queue items
|
||||||
@@ -351,6 +371,7 @@ If migrating from the old system:
|
|||||||
### Backward Compatibility
|
### Backward Compatibility
|
||||||
|
|
||||||
The legacy endpoints are still available but deprecated:
|
The legacy endpoints are still available but deprecated:
|
||||||
|
|
||||||
- They will return `410 Gone` status with migration instructions
|
- They will return `410 Gone` status with migration instructions
|
||||||
- Support will be removed in a future version
|
- Support will be removed in a future version
|
||||||
- All new development should use the queue endpoints
|
- All new development should use the queue endpoints
|
||||||
@@ -383,4 +404,3 @@ This project is licensed under the MIT License - see the LICENSE file for detail
|
|||||||
- [Tandoor Recipe Manager](https://docs.tandoor.dev/) - Recipe management system
|
- [Tandoor Recipe Manager](https://docs.tandoor.dev/) - Recipe management system
|
||||||
- [Workbox](https://developers.google.com/web/tools/workbox) - PWA capabilities
|
- [Workbox](https://developers.google.com/web/tools/workbox) - PWA capabilities
|
||||||
- [fastq](https://github.com/mcollina/fastq) - High-performance queue processing
|
- [fastq](https://github.com/mcollina/fastq) - High-performance queue processing
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ services:
|
|||||||
container_name: insta-recipe
|
container_name: insta-recipe
|
||||||
network_mode: host
|
network_mode: host
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- '3000:3000'
|
||||||
environment:
|
environment:
|
||||||
# LLM Configuration (Required)
|
# LLM Configuration (Required)
|
||||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
|
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
|
||||||
@@ -40,7 +40,13 @@ services:
|
|||||||
- ./secrets:/app/secrets
|
- ./secrets:/app/secrets
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"]
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'node',
|
||||||
|
'-e',
|
||||||
|
"fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||||
|
]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ services:
|
|||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- '5173:5173'
|
||||||
environment:
|
environment:
|
||||||
- PLAYWRIGHT_WS_ENDPOINT=ws://playwright-service:3000
|
- PLAYWRIGHT_WS_ENDPOINT=ws://playwright-service:3000
|
||||||
- OPENAI_BASE_URL=http://ollama:11434/v1
|
- OPENAI_BASE_URL=http://ollama:11434/v1
|
||||||
@@ -18,7 +18,7 @@ services:
|
|||||||
playwright-service:
|
playwright-service:
|
||||||
build: ./playwright-service
|
build: ./playwright-service
|
||||||
ipc: host
|
ipc: host
|
||||||
ports: ["3000:3000"]
|
ports: ['3000:3000']
|
||||||
environment:
|
environment:
|
||||||
- DISPLAY=:99
|
- DISPLAY=:99
|
||||||
security_opt:
|
security_opt:
|
||||||
@@ -26,7 +26,7 @@ services:
|
|||||||
|
|
||||||
ollama:
|
ollama:
|
||||||
image: ollama/ollama:latest
|
image: ollama/ollama:latest
|
||||||
ports: ["11434:11434"]
|
ports: ['11434:11434']
|
||||||
volumes:
|
volumes:
|
||||||
- ollama_data:/root/.ollama
|
- ollama_data:/root/.ollama
|
||||||
|
|
||||||
|
|||||||
46
docs/API.md
46
docs/API.md
@@ -5,6 +5,7 @@ This document describes the InstaRecipe API endpoints for the async queue-based
|
|||||||
## Base URL
|
## Base URL
|
||||||
|
|
||||||
All API endpoints are relative to your InstaRecipe instance:
|
All API endpoints are relative to your InstaRecipe instance:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://your-instarecipe-instance.com/api
|
https://your-instarecipe-instance.com/api
|
||||||
```
|
```
|
||||||
@@ -25,11 +26,14 @@ All endpoints return standardized error responses:
|
|||||||
{
|
{
|
||||||
"error": "Error type",
|
"error": "Error type",
|
||||||
"message": "Human-readable error message",
|
"message": "Human-readable error message",
|
||||||
"details": { /* Additional error context */ }
|
"details": {
|
||||||
|
/* Additional error context */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
HTTP status codes follow REST conventions:
|
HTTP status codes follow REST conventions:
|
||||||
|
|
||||||
- `200` - Success
|
- `200` - Success
|
||||||
- `201` - Created
|
- `201` - Created
|
||||||
- `400` - Bad Request (invalid input)
|
- `400` - Bad Request (invalid input)
|
||||||
@@ -45,6 +49,7 @@ HTTP status codes follow REST conventions:
|
|||||||
Enqueue an Instagram URL for async processing.
|
Enqueue an Instagram URL for async processing.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"url": "https://instagram.com/p/abc123"
|
"url": "https://instagram.com/p/abc123"
|
||||||
@@ -52,6 +57,7 @@ Enqueue an Instagram URL for async processing.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Supported URL Formats:**
|
**Supported URL Formats:**
|
||||||
|
|
||||||
- Posts: `https://instagram.com/p/{post-id}`
|
- Posts: `https://instagram.com/p/{post-id}`
|
||||||
- Posts (www): `https://www.instagram.com/p/{post-id}`
|
- Posts (www): `https://www.instagram.com/p/{post-id}`
|
||||||
- Reels: `https://instagram.com/reel/{reel-id}`
|
- Reels: `https://instagram.com/reel/{reel-id}`
|
||||||
@@ -59,12 +65,14 @@ Enqueue an Instagram URL for async processing.
|
|||||||
- With query parameters: `https://instagram.com/reel/{reel-id}?utm_source=share`
|
- With query parameters: `https://instagram.com/reel/{reel-id}?utm_source=share`
|
||||||
|
|
||||||
**URL Requirements:**
|
**URL Requirements:**
|
||||||
|
|
||||||
- Must use HTTPS protocol
|
- Must use HTTPS protocol
|
||||||
- Hostname must be `instagram.com` or `www.instagram.com`
|
- Hostname must be `instagram.com` or `www.instagram.com`
|
||||||
- Any Instagram path is accepted (posts, reels, IGTV, etc.)
|
- Any Instagram path is accepted (posts, reels, IGTV, etc.)
|
||||||
- Query parameters and hash fragments are allowed
|
- Query parameters and hash fragments are allowed
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// Post URL
|
// Post URL
|
||||||
{ "url": "https://instagram.com/p/ABC123" }
|
{ "url": "https://instagram.com/p/ABC123" }
|
||||||
@@ -77,6 +85,7 @@ Enqueue an Instagram URL for async processing.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response (201 Created):**
|
**Response (201 Created):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
@@ -105,6 +114,7 @@ Enqueue an Instagram URL for async processing.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `400` - Invalid URL format (not a valid URL)
|
- `400` - Invalid URL format (not a valid URL)
|
||||||
- `400` - URL must use HTTPS protocol
|
- `400` - URL must use HTTPS protocol
|
||||||
- `400` - URL must be from instagram.com domain
|
- `400` - URL must be from instagram.com domain
|
||||||
@@ -115,6 +125,7 @@ Enqueue an Instagram URL for async processing.
|
|||||||
List queue items with optional filtering, pagination, and sorting.
|
List queue items with optional filtering, pagination, and sorting.
|
||||||
|
|
||||||
**Query Parameters:**
|
**Query Parameters:**
|
||||||
|
|
||||||
- `status` (optional): Filter by status (`pending`, `in_progress`, `success`, `error`, `unhealthy`)
|
- `status` (optional): Filter by status (`pending`, `in_progress`, `success`, `error`, `unhealthy`)
|
||||||
- `limit` (optional): Number of items to return (default: 50, max: 100)
|
- `limit` (optional): Number of items to return (default: 50, max: 100)
|
||||||
- `offset` (optional): Number of items to skip (default: 0)
|
- `offset` (optional): Number of items to skip (default: 0)
|
||||||
@@ -122,6 +133,7 @@ List queue items with optional filtering, pagination, and sorting.
|
|||||||
- `order` (optional): Sort order (`asc`, `desc`) (default: `desc`)
|
- `order` (optional): Sort order (`asc`, `desc`) (default: `desc`)
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET /api/queue # All items
|
GET /api/queue # All items
|
||||||
GET /api/queue?status=error # Failed items only
|
GET /api/queue?status=error # Failed items only
|
||||||
@@ -130,6 +142,7 @@ GET /api/queue?sort=status&order=asc # Sort by status
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
@@ -199,12 +212,14 @@ GET /api/queue?sort=status&order=asc # Sort by status
|
|||||||
Get details for a specific queue item.
|
Get details for a specific queue item.
|
||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `id`: Queue item UUID
|
- `id`: Queue item UUID
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
Returns the same queue item structure as in the list response.
|
Returns the same queue item structure as in the list response.
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `400` - Invalid UUID format
|
- `400` - Invalid UUID format
|
||||||
- `404` - Queue item not found
|
- `404` - Queue item not found
|
||||||
|
|
||||||
@@ -213,9 +228,11 @@ Returns the same queue item structure as in the list response.
|
|||||||
Retry a failed queue item. Only items with `error` or `unhealthy` status can be retried.
|
Retry a failed queue item. Only items with `error` or `unhealthy` status can be retried.
|
||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `id`: Queue item UUID
|
- `id`: Queue item UUID
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -229,6 +246,7 @@ Retry a failed queue item. Only items with `error` or `unhealthy` status can be
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `400` - Invalid UUID format
|
- `400` - Invalid UUID format
|
||||||
- `404` - Queue item not found
|
- `404` - Queue item not found
|
||||||
- `409` - Cannot retry item with current status (only `error` and `unhealthy` can be retried)
|
- `409` - Cannot retry item with current status (only `error` and `unhealthy` can be retried)
|
||||||
@@ -240,10 +258,12 @@ Retry a failed queue item. Only items with `error` or `unhealthy` status can be
|
|||||||
Server-Sent Events (SSE) endpoint for real-time queue updates.
|
Server-Sent Events (SSE) endpoint for real-time queue updates.
|
||||||
|
|
||||||
**Query Parameters:**
|
**Query Parameters:**
|
||||||
|
|
||||||
- `itemId` (optional): Filter updates for specific item
|
- `itemId` (optional): Filter updates for specific item
|
||||||
- `status` (optional): Filter updates by status
|
- `status` (optional): Filter updates by status
|
||||||
|
|
||||||
**Headers:**
|
**Headers:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Accept: text/event-stream
|
Accept: text/event-stream
|
||||||
Cache-Control: no-cache
|
Cache-Control: no-cache
|
||||||
@@ -253,14 +273,18 @@ Cache-Control: no-cache
|
|||||||
SSE stream with the following event types:
|
SSE stream with the following event types:
|
||||||
|
|
||||||
#### connection
|
#### connection
|
||||||
|
|
||||||
Sent when connection is established:
|
Sent when connection is established:
|
||||||
|
|
||||||
```
|
```
|
||||||
event: connection
|
event: connection
|
||||||
data: {"message": "Connected to queue stream", "timestamp": "2024-12-21T10:30:00Z"}
|
data: {"message": "Connected to queue stream", "timestamp": "2024-12-21T10:30:00Z"}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### queue-update
|
#### queue-update
|
||||||
|
|
||||||
Sent when queue item status changes:
|
Sent when queue item status changes:
|
||||||
|
|
||||||
```
|
```
|
||||||
event: queue-update
|
event: queue-update
|
||||||
data: {
|
data: {
|
||||||
@@ -279,7 +303,9 @@ data: {
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### ping
|
#### ping
|
||||||
|
|
||||||
Keep-alive ping sent every 30 seconds:
|
Keep-alive ping sent every 30 seconds:
|
||||||
|
|
||||||
```
|
```
|
||||||
event: ping
|
event: ping
|
||||||
data: {"timestamp": "2024-12-21T10:30:30Z"}
|
data: {"timestamp": "2024-12-21T10:30:30Z"}
|
||||||
@@ -288,6 +314,7 @@ data: {"timestamp": "2024-12-21T10:30:30Z"}
|
|||||||
**Usage Examples:**
|
**Usage Examples:**
|
||||||
|
|
||||||
**JavaScript:**
|
**JavaScript:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const eventSource = new EventSource('/api/queue/stream');
|
const eventSource = new EventSource('/api/queue/stream');
|
||||||
|
|
||||||
@@ -312,6 +339,7 @@ eventSource.onerror = (error) => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**curl:**
|
**curl:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -N -H "Accept: text/event-stream" \
|
curl -N -H "Accept: text/event-stream" \
|
||||||
"https://your-instance.com/api/queue/stream?itemId=550e8400-e29b-41d4-a716-446655440000"
|
"https://your-instance.com/api/queue/stream?itemId=550e8400-e29b-41d4-a716-446655440000"
|
||||||
@@ -324,6 +352,7 @@ curl -N -H "Accept: text/event-stream" \
|
|||||||
Get the VAPID public key required for push notification subscriptions.
|
Get the VAPID public key required for push notification subscriptions.
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"publicKey": "BDummyPublicKeyForDevelopment...",
|
"publicKey": "BDummyPublicKeyForDevelopment...",
|
||||||
@@ -336,6 +365,7 @@ Get the VAPID public key required for push notification subscriptions.
|
|||||||
Subscribe to push notifications for queue processing updates.
|
Subscribe to push notifications for queue processing updates.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"subscription": {
|
"subscription": {
|
||||||
@@ -350,6 +380,7 @@ Subscribe to push notifications for queue processing updates.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -359,6 +390,7 @@ Subscribe to push notifications for queue processing updates.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `400` - Invalid subscription object or missing clientId
|
- `400` - Invalid subscription object or missing clientId
|
||||||
|
|
||||||
### DELETE /api/notifications/subscribe
|
### DELETE /api/notifications/subscribe
|
||||||
@@ -366,6 +398,7 @@ Subscribe to push notifications for queue processing updates.
|
|||||||
Unsubscribe from push notifications.
|
Unsubscribe from push notifications.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"clientId": "unique-client-identifier"
|
"clientId": "unique-client-identifier"
|
||||||
@@ -373,6 +406,7 @@ Unsubscribe from push notifications.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -390,6 +424,7 @@ Unsubscribe from push notifications.
|
|||||||
This synchronous extraction endpoint is deprecated. Use `POST /api/queue` instead.
|
This synchronous extraction endpoint is deprecated. Use `POST /api/queue` instead.
|
||||||
|
|
||||||
**Migration:**
|
**Migration:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// ❌ Old synchronous approach
|
// ❌ Old synchronous approach
|
||||||
const response = await fetch('/api/extract', {
|
const response = await fetch('/api/extract', {
|
||||||
@@ -413,6 +448,7 @@ const queueItem = await response.json(); // Immediate response
|
|||||||
This SSE endpoint is deprecated. Use `POST /api/queue` + `GET /api/queue/stream` instead.
|
This SSE endpoint is deprecated. Use `POST /api/queue` + `GET /api/queue/stream` instead.
|
||||||
|
|
||||||
**Migration:**
|
**Migration:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// ❌ Old approach
|
// ❌ Old approach
|
||||||
const response = await fetch('/api/extract-stream', {
|
const response = await fetch('/api/extract-stream', {
|
||||||
@@ -485,7 +521,8 @@ interface Recipe {
|
|||||||
|
|
||||||
keywords?: string[]; // Recipe tags
|
keywords?: string[]; // Recipe tags
|
||||||
image?: string; // Image URL
|
image?: string; // Image URL
|
||||||
nutrition?: { // Nutritional information
|
nutrition?: {
|
||||||
|
// Nutritional information
|
||||||
calories?: number;
|
calories?: number;
|
||||||
protein?: number;
|
protein?: number;
|
||||||
carbs?: number;
|
carbs?: number;
|
||||||
@@ -552,7 +589,6 @@ async function processInstagramUrl(url) {
|
|||||||
reject(error);
|
reject(error);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Processing failed:', error);
|
console.error('Processing failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -561,13 +597,13 @@ async function processInstagramUrl(url) {
|
|||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
processInstagramUrl('https://instagram.com/p/abc123')
|
processInstagramUrl('https://instagram.com/p/abc123')
|
||||||
.then(results => {
|
.then((results) => {
|
||||||
console.log('Recipe extracted:', results.recipe);
|
console.log('Recipe extracted:', results.recipe);
|
||||||
if (results.tandoorUrl) {
|
if (results.tandoorUrl) {
|
||||||
console.log('Uploaded to Tandoor:', results.tandoorUrl);
|
console.log('Uploaded to Tandoor:', results.tandoorUrl);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
console.error('Extraction failed:', error.message);
|
console.error('Extraction failed:', error.message);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -91,21 +91,27 @@ insta-recipe/
|
|||||||
## Key Directories
|
## Key Directories
|
||||||
|
|
||||||
### `/src/lib/server/`
|
### `/src/lib/server/`
|
||||||
|
|
||||||
Server-side business logic following Hexagonal Architecture principles. Contains domain logic, adapters for external systems (Instagram, Tandoor, LLM), and port definitions.
|
Server-side business logic following Hexagonal Architecture principles. Contains domain logic, adapters for external systems (Instagram, Tandoor, LLM), and port definitions.
|
||||||
|
|
||||||
### `/src/lib/client/`
|
### `/src/lib/client/`
|
||||||
|
|
||||||
Client-side utilities for PWA features (push notifications, install prompts, service worker messaging).
|
Client-side utilities for PWA features (push notifications, install prompts, service worker messaging).
|
||||||
|
|
||||||
### `/src/routes/api/`
|
### `/src/routes/api/`
|
||||||
|
|
||||||
RESTful API endpoints implemented as SvelteKit server routes. Each directory contains `+server.ts` files exporting HTTP verb handlers.
|
RESTful API endpoints implemented as SvelteKit server routes. Each directory contains `+server.ts` files exporting HTTP verb handlers.
|
||||||
|
|
||||||
### `/src/routes/share/`
|
### `/src/routes/share/`
|
||||||
|
|
||||||
Share target page allowing users to share Instagram URLs directly from their browser or mobile apps.
|
Share target page allowing users to share Instagram URLs directly from their browser or mobile apps.
|
||||||
|
|
||||||
### `/src/lib/server/queue/`
|
### `/src/lib/server/queue/`
|
||||||
|
|
||||||
Queue management system with in-memory storage, processor workers, and type definitions.
|
Queue management system with in-memory storage, processor workers, and type definitions.
|
||||||
|
|
||||||
### `/docs/`
|
### `/docs/`
|
||||||
|
|
||||||
Comprehensive documentation including plans, outcomes, API specs, and migration guides.
|
Comprehensive documentation including plans, outcomes, API specs, and migration guides.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -113,33 +119,43 @@ Comprehensive documentation including plans, outcomes, API specs, and migration
|
|||||||
## Design Patterns
|
## Design Patterns
|
||||||
|
|
||||||
### Singleton Pattern
|
### Singleton Pattern
|
||||||
|
|
||||||
Used for shared service instances:
|
Used for shared service instances:
|
||||||
|
|
||||||
- `QueueManager` (`queueManager` exported instance)
|
- `QueueManager` (`queueManager` exported instance)
|
||||||
- `QueueProcessor` (`queueProcessor` exported instance)
|
- `QueueProcessor` (`queueProcessor` exported instance)
|
||||||
- `PushNotificationService` (`pushNotificationService` exported instance)
|
- `PushNotificationService` (`pushNotificationService` exported instance)
|
||||||
- `ServiceWorkerMessageHandler` (`serviceWorkerMessageHandler` exported instance)
|
- `ServiceWorkerMessageHandler` (`serviceWorkerMessageHandler` exported instance)
|
||||||
|
|
||||||
### Factory Pattern
|
### Factory Pattern
|
||||||
|
|
||||||
Used for creating configured instances:
|
Used for creating configured instances:
|
||||||
|
|
||||||
- `createLLM()` - Creates OpenAI client with environment configuration
|
- `createLLM()` - Creates OpenAI client with environment configuration
|
||||||
- `createBrowserContext()` - Creates Playwright browser context with options
|
- `createBrowserContext()` - Creates Playwright browser context with options
|
||||||
- `initializeBrowser()` - Initializes Chromium browser instance
|
- `initializeBrowser()` - Initializes Chromium browser instance
|
||||||
|
|
||||||
### Observer Pattern
|
### Observer Pattern
|
||||||
|
|
||||||
Implemented in QueueManager for real-time updates:
|
Implemented in QueueManager for real-time updates:
|
||||||
|
|
||||||
- Subscribers receive notifications on queue item changes
|
- Subscribers receive notifications on queue item changes
|
||||||
- Server-Sent Events (SSE) stream queue updates to clients
|
- Server-Sent Events (SSE) stream queue updates to clients
|
||||||
- Push notifications notify users of completion events
|
- Push notifications notify users of completion events
|
||||||
|
|
||||||
### Adapter Pattern (Hexagonal Architecture)
|
### Adapter Pattern (Hexagonal Architecture)
|
||||||
|
|
||||||
External systems accessed via adapters:
|
External systems accessed via adapters:
|
||||||
|
|
||||||
- **Instagram Adapter**: `extraction.ts` - Extracts content via Playwright
|
- **Instagram Adapter**: `extraction.ts` - Extracts content via Playwright
|
||||||
- **LLM Adapter**: `llm.ts`, `parser.ts` - Recipe parsing via OpenAI
|
- **LLM Adapter**: `llm.ts`, `parser.ts` - Recipe parsing via OpenAI
|
||||||
- **Tandoor Adapter**: `tandoor.ts` - Recipe management system integration
|
- **Tandoor Adapter**: `tandoor.ts` - Recipe management system integration
|
||||||
- **Browser Adapter**: `browser.ts` - Playwright browser automation
|
- **Browser Adapter**: `browser.ts` - Playwright browser automation
|
||||||
|
|
||||||
### Strategy Pattern
|
### Strategy Pattern
|
||||||
|
|
||||||
Multiple extraction strategies with fallback:
|
Multiple extraction strategies with fallback:
|
||||||
|
|
||||||
1. Embedded JSON extraction
|
1. Embedded JSON extraction
|
||||||
2. DOM selector extraction
|
2. DOM selector extraction
|
||||||
3. GraphQL API extraction
|
3. GraphQL API extraction
|
||||||
@@ -150,28 +166,34 @@ Multiple extraction strategies with fallback:
|
|||||||
## Key Components
|
## Key Components
|
||||||
|
|
||||||
### Queue Management System
|
### Queue Management System
|
||||||
|
|
||||||
**Location**: `src/lib/server/queue/`
|
**Location**: `src/lib/server/queue/`
|
||||||
|
|
||||||
Three-phase processing pipeline:
|
Three-phase processing pipeline:
|
||||||
|
|
||||||
1. **Extraction Phase**: Extract text and thumbnail from Instagram
|
1. **Extraction Phase**: Extract text and thumbnail from Instagram
|
||||||
2. **Parsing Phase**: Parse recipe using LLM
|
2. **Parsing Phase**: Parse recipe using LLM
|
||||||
3. **Uploading Phase**: Upload to Tandoor (if enabled)
|
3. **Uploading Phase**: Upload to Tandoor (if enabled)
|
||||||
|
|
||||||
**Components**:
|
**Components**:
|
||||||
|
|
||||||
- `QueueManager`: In-memory FIFO queue with CRUD operations
|
- `QueueManager`: In-memory FIFO queue with CRUD operations
|
||||||
- `QueueProcessor`: Worker that processes items with configurable concurrency
|
- `QueueProcessor`: Worker that processes items with configurable concurrency
|
||||||
- `types.ts`: Comprehensive type definitions for queue items and updates
|
- `types.ts`: Comprehensive type definitions for queue items and updates
|
||||||
|
|
||||||
### API Layer
|
### API Layer
|
||||||
|
|
||||||
**Location**: `src/routes/api/`
|
**Location**: `src/routes/api/`
|
||||||
|
|
||||||
RESTful endpoints for:
|
RESTful endpoints for:
|
||||||
|
|
||||||
- Queue operations (`POST /api/queue`, `GET /api/queue`, `GET /api/queue/[id]`)
|
- Queue operations (`POST /api/queue`, `GET /api/queue`, `GET /api/queue/[id]`)
|
||||||
- Real-time updates (`GET /api/queue/stream` - SSE)
|
- Real-time updates (`GET /api/queue/stream` - SSE)
|
||||||
- Push notifications (`POST /api/notifications/subscribe`)
|
- Push notifications (`POST /api/notifications/subscribe`)
|
||||||
- Health checks (`GET /api/health`, `GET /api/llm-health`)
|
- Health checks (`GET /api/health`, `GET /api/llm-health`)
|
||||||
|
|
||||||
### Client-Side Services
|
### Client-Side Services
|
||||||
|
|
||||||
**Location**: `src/lib/client/`
|
**Location**: `src/lib/client/`
|
||||||
|
|
||||||
- **PushNotificationManager**: Manages Web Push API subscriptions
|
- **PushNotificationManager**: Manages Web Push API subscriptions
|
||||||
@@ -179,14 +201,17 @@ RESTful endpoints for:
|
|||||||
- **ServiceWorkerMessageHandler**: Processes service worker messages
|
- **ServiceWorkerMessageHandler**: Processes service worker messages
|
||||||
|
|
||||||
### Instagram Extraction
|
### Instagram Extraction
|
||||||
|
|
||||||
**Location**: `src/lib/server/extraction.ts`
|
**Location**: `src/lib/server/extraction.ts`
|
||||||
|
|
||||||
Multi-method extraction with intelligent fallback:
|
Multi-method extraction with intelligent fallback:
|
||||||
|
|
||||||
- Progress callbacks for real-time feedback
|
- Progress callbacks for real-time feedback
|
||||||
- Retry logic with configurable attempts
|
- Retry logic with configurable attempts
|
||||||
- Thumbnail extraction and validation
|
- Thumbnail extraction and validation
|
||||||
|
|
||||||
### LLM Integration
|
### LLM Integration
|
||||||
|
|
||||||
**Location**: `src/lib/server/parser.ts`, `src/lib/server/llm.ts`
|
**Location**: `src/lib/server/parser.ts`, `src/lib/server/llm.ts`
|
||||||
|
|
||||||
- Recipe detection endpoint
|
- Recipe detection endpoint
|
||||||
@@ -198,6 +223,7 @@ Multi-method extraction with intelligent fallback:
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
### Production Dependencies
|
### Production Dependencies
|
||||||
|
|
||||||
- **@types/uuid** (^10.0.0) - UUID type definitions
|
- **@types/uuid** (^10.0.0) - UUID type definitions
|
||||||
- **date-fns** (^4.1.0) - Date utility library
|
- **date-fns** (^4.1.0) - Date utility library
|
||||||
- **openai** (^4.20.0) - OpenAI API client
|
- **openai** (^4.20.0) - OpenAI API client
|
||||||
@@ -206,6 +232,7 @@ Multi-method extraction with intelligent fallback:
|
|||||||
- **zod** (^3.23.0) - Schema validation
|
- **zod** (^3.23.0) - Schema validation
|
||||||
|
|
||||||
### Development Dependencies
|
### Development Dependencies
|
||||||
|
|
||||||
- **@sveltejs/kit** (^2.48.5) - SvelteKit framework
|
- **@sveltejs/kit** (^2.48.5) - SvelteKit framework
|
||||||
- **@sveltejs/adapter-node** (^5.4.0) - Node.js adapter
|
- **@sveltejs/adapter-node** (^5.4.0) - Node.js adapter
|
||||||
- **svelte** (^5.43.8) - Svelte 5 framework
|
- **svelte** (^5.43.8) - Svelte 5 framework
|
||||||
@@ -223,12 +250,14 @@ Multi-method extraction with intelligent fallback:
|
|||||||
## Module Organization
|
## Module Organization
|
||||||
|
|
||||||
### SvelteKit Path Aliases
|
### SvelteKit Path Aliases
|
||||||
|
|
||||||
- `$lib` → `src/lib/`
|
- `$lib` → `src/lib/`
|
||||||
- `$lib/*` → `src/lib/*`
|
- `$lib/*` → `src/lib/*`
|
||||||
- `$app/*` → SvelteKit app imports
|
- `$app/*` → SvelteKit app imports
|
||||||
- `$env/dynamic/private` → Environment variables (server-side)
|
- `$env/dynamic/private` → Environment variables (server-side)
|
||||||
|
|
||||||
### Directory Structure Conventions
|
### Directory Structure Conventions
|
||||||
|
|
||||||
- **Server-only code**: `src/lib/server/` (not bundled to client)
|
- **Server-only code**: `src/lib/server/` (not bundled to client)
|
||||||
- **Client-only code**: `src/lib/client/` (not executed on server)
|
- **Client-only code**: `src/lib/client/` (not executed on server)
|
||||||
- **Shared code**: `src/lib/` (available to both)
|
- **Shared code**: `src/lib/` (available to both)
|
||||||
@@ -240,6 +269,7 @@ Multi-method extraction with intelligent fallback:
|
|||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
### Recipe Extraction Flow
|
### Recipe Extraction Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
User submits URL
|
User submits URL
|
||||||
↓
|
↓
|
||||||
@@ -261,6 +291,7 @@ SSE updates notify client
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Real-time Updates Flow
|
### Real-time Updates Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Client connects to GET /api/queue/stream (SSE)
|
Client connects to GET /api/queue/stream (SSE)
|
||||||
↓
|
↓
|
||||||
@@ -274,6 +305,7 @@ Client updates UI reactively
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Push Notification Flow
|
### Push Notification Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Client requests permission
|
Client requests permission
|
||||||
↓
|
↓
|
||||||
@@ -295,37 +327,44 @@ Notification displayed to user
|
|||||||
## Build System
|
## Build System
|
||||||
|
|
||||||
### Build Command
|
### Build Command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Generates production-ready build in `build/` directory using:
|
Generates production-ready build in `build/` directory using:
|
||||||
|
|
||||||
- Vite for bundling
|
- Vite for bundling
|
||||||
- `@sveltejs/adapter-node` for Node.js deployment
|
- `@sveltejs/adapter-node` for Node.js deployment
|
||||||
- TypeScript compilation
|
- TypeScript compilation
|
||||||
- SvelteKit prerendering and optimization
|
- SvelteKit prerendering and optimization
|
||||||
|
|
||||||
### Test Command
|
### Test Command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs test suite using Vitest with two projects:
|
Runs test suite using Vitest with two projects:
|
||||||
|
|
||||||
1. **Server tests**: Node environment for server-side code
|
1. **Server tests**: Node environment for server-side code
|
||||||
2. **Client tests**: Playwright browser for Svelte components
|
2. **Client tests**: Playwright browser for Svelte components
|
||||||
|
|
||||||
### Development Server
|
### Development Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Starts Vite dev server with:
|
Starts Vite dev server with:
|
||||||
|
|
||||||
- HTTPS enabled (certificates in `.ssl/`)
|
- HTTPS enabled (certificates in `.ssl/`)
|
||||||
- Hot module replacement
|
- Hot module replacement
|
||||||
- TypeScript checking
|
- TypeScript checking
|
||||||
- File watching
|
- File watching
|
||||||
|
|
||||||
### Linting & Formatting
|
### Linting & Formatting
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lint # ESLint + Prettier check
|
npm run lint # ESLint + Prettier check
|
||||||
npm run format # Prettier write
|
npm run format # Prettier write
|
||||||
@@ -336,19 +375,24 @@ npm run format # Prettier write
|
|||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Docker Deployment
|
### Docker Deployment
|
||||||
|
|
||||||
Dockerfile includes:
|
Dockerfile includes:
|
||||||
|
|
||||||
- Node.js 22 Alpine base image
|
- Node.js 22 Alpine base image
|
||||||
- Playwright Chromium installation
|
- Playwright Chromium installation
|
||||||
- Production build
|
- Production build
|
||||||
- Port 3000 exposure
|
- Port 3000 exposure
|
||||||
|
|
||||||
Run with:
|
Run with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Required configuration:
|
Required configuration:
|
||||||
|
|
||||||
- `OPENAI_API_KEY` - LLM API access
|
- `OPENAI_API_KEY` - LLM API access
|
||||||
- `TANDOOR_URL` - Tandoor instance URL (optional)
|
- `TANDOOR_URL` - Tandoor instance URL (optional)
|
||||||
- `TANDOOR_TOKEN` - Tandoor API token (optional)
|
- `TANDOOR_TOKEN` - Tandoor API token (optional)
|
||||||
@@ -360,13 +404,16 @@ Required configuration:
|
|||||||
## Testing Architecture
|
## Testing Architecture
|
||||||
|
|
||||||
### Test Categories
|
### Test Categories
|
||||||
|
|
||||||
1. **Unit Tests**: Individual function testing
|
1. **Unit Tests**: Individual function testing
|
||||||
2. **Integration Tests**: Multi-component workflows
|
2. **Integration Tests**: Multi-component workflows
|
||||||
3. **API Tests**: Endpoint behavior validation
|
3. **API Tests**: Endpoint behavior validation
|
||||||
4. **Browser Tests**: Svelte component rendering
|
4. **Browser Tests**: Svelte component rendering
|
||||||
|
|
||||||
### Test Coverage
|
### Test Coverage
|
||||||
|
|
||||||
138 tests covering:
|
138 tests covering:
|
||||||
|
|
||||||
- Queue management operations
|
- Queue management operations
|
||||||
- Instagram URL validation
|
- Instagram URL validation
|
||||||
- SSE streaming
|
- SSE streaming
|
||||||
@@ -375,6 +422,7 @@ Required configuration:
|
|||||||
- Notification service
|
- Notification service
|
||||||
|
|
||||||
### Test Configuration
|
### Test Configuration
|
||||||
|
|
||||||
- **Server tests**: Node environment with mocked dependencies
|
- **Server tests**: Node environment with mocked dependencies
|
||||||
- **Client tests**: Playwright Chromium browser with Svelte testing library
|
- **Client tests**: Playwright Chromium browser with Svelte testing library
|
||||||
|
|
||||||
@@ -383,15 +431,18 @@ Required configuration:
|
|||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### SSL/TLS
|
### SSL/TLS
|
||||||
|
|
||||||
- Development uses local SSL certificates signed by external Caddy CA
|
- Development uses local SSL certificates signed by external Caddy CA
|
||||||
- Certificates stored in `.ssl/` (git-ignored)
|
- Certificates stored in `.ssl/` (git-ignored)
|
||||||
- Required for PWA features (Service Worker, Push API)
|
- Required for PWA features (Service Worker, Push API)
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- Basic auth for scheduled tasks (username/password from environment)
|
- Basic auth for scheduled tasks (username/password from environment)
|
||||||
- Tandoor integration uses bearer token authentication
|
- Tandoor integration uses bearer token authentication
|
||||||
|
|
||||||
### Input Validation
|
### Input Validation
|
||||||
|
|
||||||
- Instagram URL validation with regex patterns
|
- Instagram URL validation with regex patterns
|
||||||
- Zod schema validation for API payloads
|
- Zod schema validation for API payloads
|
||||||
- Error handling with custom error classes
|
- Error handling with custom error classes
|
||||||
|
|||||||
@@ -19,12 +19,14 @@
|
|||||||
### Files & Directories
|
### Files & Directories
|
||||||
|
|
||||||
#### SvelteKit Route Files
|
#### SvelteKit Route Files
|
||||||
|
|
||||||
- Route pages: `+page.svelte`
|
- Route pages: `+page.svelte`
|
||||||
- Route servers: `+server.ts`
|
- Route servers: `+server.ts`
|
||||||
- Route layouts: `+layout.svelte`
|
- Route layouts: `+layout.svelte`
|
||||||
- Type definitions: `$types.ts` (auto-generated)
|
- Type definitions: `$types.ts` (auto-generated)
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```
|
```
|
||||||
src/routes/api/queue/
|
src/routes/api/queue/
|
||||||
├── [id]/
|
├── [id]/
|
||||||
@@ -37,19 +39,23 @@ src/routes/api/queue/
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Library Files
|
#### Library Files
|
||||||
|
|
||||||
- **PascalCase** for classes and managers: `QueueManager.ts`, `PushNotificationService.ts`
|
- **PascalCase** for classes and managers: `QueueManager.ts`, `PushNotificationService.ts`
|
||||||
- **kebab-case** for utilities and configs: `tandoor-config.ts`, `instagram-url.ts`
|
- **kebab-case** for utilities and configs: `tandoor-config.ts`, `instagram-url.ts`
|
||||||
- **lowercase** for general modules: `browser.ts`, `extraction.ts`, `parser.ts`
|
- **lowercase** for general modules: `browser.ts`, `extraction.ts`, `parser.ts`
|
||||||
|
|
||||||
**Examples from codebase:**
|
**Examples from codebase:**
|
||||||
|
|
||||||
- `src/lib/server/queue/QueueManager.ts`
|
- `src/lib/server/queue/QueueManager.ts`
|
||||||
- `src/lib/server/tandoor-config.ts`
|
- `src/lib/server/tandoor-config.ts`
|
||||||
- `src/lib/client/PushNotificationManager.ts`
|
- `src/lib/client/PushNotificationManager.ts`
|
||||||
|
|
||||||
#### Test Files
|
#### Test Files
|
||||||
|
|
||||||
Pattern: `<name>.spec.ts` or `<name>.test.ts`
|
Pattern: `<name>.spec.ts` or `<name>.test.ts`
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
- `queue-manager.spec.ts`
|
- `queue-manager.spec.ts`
|
||||||
- `instagram-url-validation.spec.ts`
|
- `instagram-url-validation.spec.ts`
|
||||||
- `page.svelte.spec.ts`
|
- `page.svelte.spec.ts`
|
||||||
@@ -57,10 +63,12 @@ Pattern: `<name>.spec.ts` or `<name>.test.ts`
|
|||||||
### Variables & Functions
|
### Variables & Functions
|
||||||
|
|
||||||
#### Variables
|
#### Variables
|
||||||
|
|
||||||
- **camelCase** for local variables and parameters
|
- **camelCase** for local variables and parameters
|
||||||
- **SCREAMING_SNAKE_CASE** for constants
|
- **SCREAMING_SNAKE_CASE** for constants
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// From QueueManager.ts
|
// From QueueManager.ts
|
||||||
private items: Map<string, QueueItem> = new Map();
|
private items: Map<string, QueueItem> = new Map();
|
||||||
@@ -76,10 +84,12 @@ const unsubscribe = queueManager.subscribe(callback);
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Functions
|
#### Functions
|
||||||
|
|
||||||
- **camelCase** for function names
|
- **camelCase** for function names
|
||||||
- **Descriptive action verbs**: `enqueue`, `extractRecipe`, `uploadRecipeImage`
|
- **Descriptive action verbs**: `enqueue`, `extractRecipe`, `uploadRecipeImage`
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// From QueueManager.ts
|
// From QueueManager.ts
|
||||||
enqueue(url: string): QueueItem { ... }
|
enqueue(url: string): QueueItem { ... }
|
||||||
@@ -99,11 +109,13 @@ export async function extractRecipe(text: string): Promise<Recipe> { ... }
|
|||||||
### Types & Interfaces
|
### Types & Interfaces
|
||||||
|
|
||||||
#### Interfaces & Types
|
#### Interfaces & Types
|
||||||
|
|
||||||
- **PascalCase** for interface names
|
- **PascalCase** for interface names
|
||||||
- Prefix with `I` is **NOT** used
|
- Prefix with `I` is **NOT** used
|
||||||
- Exported types use `export type` or `export interface`
|
- Exported types use `export type` or `export interface`
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// From queue/types.ts
|
// From queue/types.ts
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
@@ -121,11 +133,7 @@ export interface QueueStatusUpdate {
|
|||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueueItemStatus =
|
export type QueueItemStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||||
| 'pending'
|
|
||||||
| 'in_progress'
|
|
||||||
| 'completed'
|
|
||||||
| 'failed';
|
|
||||||
|
|
||||||
// From extraction.ts
|
// From extraction.ts
|
||||||
export interface ExtractedContent {
|
export interface ExtractedContent {
|
||||||
@@ -137,16 +145,18 @@ export type ProgressCallback = (event: ProgressEvent) => void;
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Zod Schemas
|
#### Zod Schemas
|
||||||
|
|
||||||
- **PascalCase** with `Schema` suffix
|
- **PascalCase** with `Schema` suffix
|
||||||
- Inferred types without suffix
|
- Inferred types without suffix
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// From parser.ts
|
// From parser.ts
|
||||||
const RecipeSchema = z.object({
|
const RecipeSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
servings: z.number(),
|
servings: z.number()
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,10 +173,12 @@ export type TandoorRecipe = z.infer<typeof TandoorRecipeSchema>;
|
|||||||
### Classes
|
### Classes
|
||||||
|
|
||||||
#### Class Names
|
#### Class Names
|
||||||
|
|
||||||
- **PascalCase** for class names
|
- **PascalCase** for class names
|
||||||
- Descriptive suffixes: `Manager`, `Service`, `Processor`, `Handler`
|
- Descriptive suffixes: `Manager`, `Service`, `Processor`, `Handler`
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// From QueueManager.ts
|
// From QueueManager.ts
|
||||||
export class QueueManager {
|
export class QueueManager {
|
||||||
@@ -188,6 +200,7 @@ class PushNotificationService {
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Singleton Export Pattern
|
#### Singleton Export Pattern
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Class definition
|
// Class definition
|
||||||
export class QueueManager {
|
export class QueueManager {
|
||||||
@@ -203,6 +216,7 @@ export const queueManager = new QueueManager();
|
|||||||
## Indentation & Formatting
|
## Indentation & Formatting
|
||||||
|
|
||||||
### General Rules
|
### General Rules
|
||||||
|
|
||||||
- **Indentation:** 2 spaces (enforced by Prettier)
|
- **Indentation:** 2 spaces (enforced by Prettier)
|
||||||
- **No tabs**
|
- **No tabs**
|
||||||
- **Max line length:** 100 characters (soft limit, not enforced)
|
- **Max line length:** 100 characters (soft limit, not enforced)
|
||||||
@@ -213,6 +227,7 @@ export const queueManager = new QueueManager();
|
|||||||
### Code Examples
|
### Code Examples
|
||||||
|
|
||||||
#### Function Declarations
|
#### Function Declarations
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// From QueueManager.ts
|
// From QueueManager.ts
|
||||||
enqueue(url: string): QueueItem {
|
enqueue(url: string): QueueItem {
|
||||||
@@ -241,6 +256,7 @@ enqueue(url: string): QueueItem {
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Async Functions
|
#### Async Functions
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// From extraction.ts
|
// From extraction.ts
|
||||||
export async function extractTextAndThumbnail(
|
export async function extractTextAndThumbnail(
|
||||||
@@ -261,6 +277,7 @@ export async function extractTextAndThumbnail(
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Object Destructuring
|
#### Object Destructuring
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// From route handlers
|
// From route handlers
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
@@ -279,12 +296,14 @@ export const GET: RequestHandler = async ({ params }) => {
|
|||||||
## Import Patterns
|
## Import Patterns
|
||||||
|
|
||||||
### Import Order
|
### Import Order
|
||||||
|
|
||||||
1. External dependencies (Node.js built-ins, npm packages)
|
1. External dependencies (Node.js built-ins, npm packages)
|
||||||
2. SvelteKit imports (`$lib`, `$app`, `$env`)
|
2. SvelteKit imports (`$lib`, `$app`, `$env`)
|
||||||
3. Relative imports (`./ `, `../`)
|
3. Relative imports (`./ `, `../`)
|
||||||
4. Type imports (separate from value imports when beneficial)
|
4. Type imports (separate from value imports when beneficial)
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// From QueueProcessor.ts
|
// From QueueProcessor.ts
|
||||||
|
|
||||||
@@ -307,6 +326,7 @@ import type { QueueItem } from './types';
|
|||||||
### Import Styles
|
### Import Styles
|
||||||
|
|
||||||
#### Named Imports (Preferred)
|
#### Named Imports (Preferred)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { queueManager } from '$lib/server/queue/QueueManager';
|
import { queueManager } from '$lib/server/queue/QueueManager';
|
||||||
@@ -314,12 +334,14 @@ import { validateInstagramUrl } from '$lib/server/validation/instagram-url';
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Type-Only Imports
|
#### Type-Only Imports
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import type { QueueItem, QueueItemStatus } from './types';
|
import type { QueueItem, QueueItemStatus } from './types';
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Default Imports
|
#### Default Imports
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -329,6 +351,7 @@ import path from 'path';
|
|||||||
### Export Patterns
|
### Export Patterns
|
||||||
|
|
||||||
#### Named Exports (Preferred)
|
#### Named Exports (Preferred)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Export functions
|
// Export functions
|
||||||
export async function extractRecipe(text: string): Promise<Recipe> { ... }
|
export async function extractRecipe(text: string): Promise<Recipe> { ... }
|
||||||
@@ -345,6 +368,7 @@ export interface QueueItem { ... }
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Singleton Pattern Export
|
#### Singleton Pattern Export
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Define class
|
// Define class
|
||||||
export class QueueManager { ... }
|
export class QueueManager { ... }
|
||||||
@@ -358,10 +382,12 @@ export const queueManager = new QueueManager();
|
|||||||
## Comments & Documentation
|
## Comments & Documentation
|
||||||
|
|
||||||
### JSDoc Style
|
### JSDoc Style
|
||||||
|
|
||||||
Used extensively for public APIs and exported functions.
|
Used extensively for public APIs and exported functions.
|
||||||
|
|
||||||
**Function Documentation:**
|
**Function Documentation:**
|
||||||
```typescript
|
|
||||||
|
````typescript
|
||||||
/**
|
/**
|
||||||
* Add URL to processing queue
|
* Add URL to processing queue
|
||||||
*
|
*
|
||||||
@@ -377,10 +403,11 @@ Used extensively for public APIs and exported functions.
|
|||||||
enqueue(url: string): QueueItem {
|
enqueue(url: string): QueueItem {
|
||||||
// Implementation
|
// Implementation
|
||||||
}
|
}
|
||||||
```
|
````
|
||||||
|
|
||||||
**Class Documentation:**
|
**Class Documentation:**
|
||||||
```typescript
|
|
||||||
|
````typescript
|
||||||
/**
|
/**
|
||||||
* Singleton queue manager for processing Instagram URLs
|
* Singleton queue manager for processing Instagram URLs
|
||||||
*
|
*
|
||||||
@@ -402,9 +429,10 @@ enqueue(url: string): QueueItem {
|
|||||||
export class QueueManager {
|
export class QueueManager {
|
||||||
// Implementation
|
// Implementation
|
||||||
}
|
}
|
||||||
```
|
````
|
||||||
|
|
||||||
**Module-Level Documentation:**
|
**Module-Level Documentation:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
/**
|
/**
|
||||||
* Queue Manager - Core queue operations and event management
|
* Queue Manager - Core queue operations and event management
|
||||||
@@ -421,19 +449,21 @@ export class QueueManager {
|
|||||||
### Inline Comments
|
### Inline Comments
|
||||||
|
|
||||||
#### Single-line Comments
|
#### Single-line Comments
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Set restrictive permissions
|
// Set restrictive permissions
|
||||||
fs.chmodSync(authFile, 0o600);
|
fs.chmodSync(authFile, 0o600);
|
||||||
|
|
||||||
// FIFO order - get oldest pending item
|
// FIFO order - get oldest pending item
|
||||||
const pendingItems = Array.from(this.items.values())
|
const pendingItems = Array.from(this.items.values()).filter((item) => item.status === 'pending');
|
||||||
.filter(item => item.status === 'pending');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Block Comments (Avoided)
|
#### Block Comments (Avoided)
|
||||||
|
|
||||||
Single-line comments preferred. Block comments used only for large comment blocks or temporarily disabling code during development.
|
Single-line comments preferred. Block comments used only for large comment blocks or temporarily disabling code during development.
|
||||||
|
|
||||||
### TODO Comments
|
### TODO Comments
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// TODO: Add retry logic with exponential backoff
|
// TODO: Add retry logic with exponential backoff
|
||||||
// FIXME: Handle race condition when multiple workers dequeue
|
// FIXME: Handle race condition when multiple workers dequeue
|
||||||
@@ -446,6 +476,7 @@ Single-line comments preferred. Block comments used only for large comment block
|
|||||||
### Type Safety
|
### Type Safety
|
||||||
|
|
||||||
#### Strict Mode Enabled
|
#### Strict Mode Enabled
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// tsconfig.json
|
// tsconfig.json
|
||||||
{
|
{
|
||||||
@@ -457,6 +488,7 @@ Single-line comments preferred. Block comments used only for large comment block
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Type Annotations
|
#### Type Annotations
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Explicit return types for public functions
|
// Explicit return types for public functions
|
||||||
export async function extractRecipe(text: string): Promise<Recipe> { ... }
|
export async function extractRecipe(text: string): Promise<Recipe> { ... }
|
||||||
@@ -469,28 +501,17 @@ const items = queueManager.getAll(); // Type inferred
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Union Types
|
### Union Types
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export type QueueItemStatus =
|
export type QueueItemStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||||
| 'pending'
|
|
||||||
| 'in_progress'
|
|
||||||
| 'completed'
|
|
||||||
| 'failed';
|
|
||||||
|
|
||||||
export type ProcessingPhase =
|
export type ProcessingPhase = 'extraction' | 'parsing' | 'uploading';
|
||||||
| 'extraction'
|
|
||||||
| 'parsing'
|
|
||||||
| 'uploading';
|
|
||||||
|
|
||||||
export type ProgressEventType =
|
export type ProgressEventType = 'status' | 'method' | 'retry' | 'error' | 'thumbnail' | 'complete';
|
||||||
| 'status'
|
|
||||||
| 'method'
|
|
||||||
| 'retry'
|
|
||||||
| 'error'
|
|
||||||
| 'thumbnail'
|
|
||||||
| 'complete';
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generics
|
### Generics
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Generic function
|
// Generic function
|
||||||
async function fetchFromTandoor<T>(
|
async function fetchFromTandoor<T>(
|
||||||
@@ -508,6 +529,7 @@ async function fetchFromTandoor<T>(
|
|||||||
### Runes (Reactivity)
|
### Runes (Reactivity)
|
||||||
|
|
||||||
#### $state (Reactive Variables)
|
#### $state (Reactive Variables)
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let count = $state(0);
|
let count = $state(0);
|
||||||
@@ -516,6 +538,7 @@ async function fetchFromTandoor<T>(
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### $props (Component Props)
|
#### $props (Component Props)
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
let {
|
||||||
@@ -533,6 +556,7 @@ async function fetchFromTandoor<T>(
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### $derived (Computed Values)
|
#### $derived (Computed Values)
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let count = $state(0);
|
let count = $state(0);
|
||||||
@@ -541,6 +565,7 @@ async function fetchFromTandoor<T>(
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### $effect (Side Effects)
|
#### $effect (Side Effects)
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let url = $state('');
|
let url = $state('');
|
||||||
@@ -552,6 +577,7 @@ async function fetchFromTandoor<T>(
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Component Structure
|
### Component Structure
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Imports
|
// Imports
|
||||||
@@ -593,6 +619,7 @@ async function fetchFromTandoor<T>(
|
|||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
### Custom Error Classes
|
### Custom Error Classes
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// From api/errors.ts
|
// From api/errors.ts
|
||||||
export class ValidationError extends Error {
|
export class ValidationError extends Error {
|
||||||
@@ -618,6 +645,7 @@ export class ConflictError extends Error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Try-Catch Pattern
|
### Try-Catch Pattern
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
@@ -629,7 +657,6 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
|
|
||||||
const item = queueManager.enqueue(url);
|
const item = queueManager.enqueue(url);
|
||||||
return json(item, { status: 201 });
|
return json(item, { status: 201 });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
@@ -641,6 +668,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
## Linting Configuration
|
## Linting Configuration
|
||||||
|
|
||||||
### ESLint
|
### ESLint
|
||||||
|
|
||||||
**Config:** `eslint.config.js`
|
**Config:** `eslint.config.js`
|
||||||
|
|
||||||
- Base: `@eslint/js` recommended
|
- Base: `@eslint/js` recommended
|
||||||
@@ -649,6 +677,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
- Formatting: `eslint-config-prettier`
|
- Formatting: `eslint-config-prettier`
|
||||||
|
|
||||||
**Rules:**
|
**Rules:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
@@ -658,6 +687,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Prettier
|
### Prettier
|
||||||
|
|
||||||
**Config:** `.prettierrc`
|
**Config:** `.prettierrc`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -675,6 +705,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
## Testing Conventions
|
## Testing Conventions
|
||||||
|
|
||||||
### Test Structure
|
### Test Structure
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
@@ -701,6 +732,7 @@ describe('QueueManager', () => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Mock Pattern
|
### Mock Pattern
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
vi.mock('$lib/server/extraction', () => ({
|
vi.mock('$lib/server/extraction', () => ({
|
||||||
extractTextAndThumbnail: vi.fn().mockResolvedValue({
|
extractTextAndThumbnail: vi.fn().mockResolvedValue({
|
||||||
@@ -715,6 +747,7 @@ vi.mock('$lib/server/extraction', () => ({
|
|||||||
## File Headers
|
## File Headers
|
||||||
|
|
||||||
### Module Documentation Pattern
|
### Module Documentation Pattern
|
||||||
|
|
||||||
Every major module includes a header comment:
|
Every major module includes a header comment:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -730,6 +763,7 @@ Every major module includes a header comment:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
/**
|
/**
|
||||||
* Queue Manager - Core queue operations and event management
|
* Queue Manager - Core queue operations and event management
|
||||||
@@ -748,6 +782,7 @@ Every major module includes a header comment:
|
|||||||
## Additional Conventions
|
## Additional Conventions
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
@@ -756,19 +791,24 @@ const tandoorUrl = env.TANDOOR_URL || null;
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Date Handling
|
### Date Handling
|
||||||
|
|
||||||
ISO8601 strings throughout the application:
|
ISO8601 strings throughout the application:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
// Output: "2026-02-15T12:30:45.123Z"
|
// Output: "2026-02-15T12:30:45.123Z"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Null vs Undefined
|
### Null vs Undefined
|
||||||
|
|
||||||
- `null`: Intentional absence of value
|
- `null`: Intentional absence of value
|
||||||
- `undefined`: Not yet initialized or optional parameters
|
- `undefined`: Not yet initialized or optional parameters
|
||||||
- Prefer `null` for API responses and data structures
|
- Prefer `null` for API responses and data structures
|
||||||
|
|
||||||
### Async/Await
|
### Async/Await
|
||||||
|
|
||||||
Always preferred over Promise chains:
|
Always preferred over Promise chains:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Preferred
|
// Preferred
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
@@ -780,8 +820,8 @@ async function fetchData() {
|
|||||||
// Avoid
|
// Avoid
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return fetch(url)
|
return fetch(url)
|
||||||
.then(response => response.json())
|
.then((response) => response.json())
|
||||||
.then(data => data);
|
.then((data) => data);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
377
docs/FINDINGS.md
377
docs/FINDINGS.md
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ The migration transformed InstaRecipe from a blocking, synchronous extraction sy
|
|||||||
### Architecture Transformation
|
### Architecture Transformation
|
||||||
|
|
||||||
**Before: Synchronous System**
|
**Before: Synchronous System**
|
||||||
|
|
||||||
```
|
```
|
||||||
User Request → Direct Processing → Response (wait 30-60s)
|
User Request → Direct Processing → Response (wait 30-60s)
|
||||||
↓ ↓ ↓
|
↓ ↓ ↓
|
||||||
@@ -18,6 +19,7 @@ User Request → Direct Processing → Response (wait 30-60s)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**After: Async Queue System**
|
**After: Async Queue System**
|
||||||
|
|
||||||
```
|
```
|
||||||
User Request → Queue Item Created → Immediate Response
|
User Request → Queue Item Created → Immediate Response
|
||||||
↓ ↓ ↓
|
↓ ↓ ↓
|
||||||
@@ -60,6 +62,7 @@ User Request → Queue Item Created → Immediate Response
|
|||||||
### New Endpoints
|
### New Endpoints
|
||||||
|
|
||||||
#### Queue Management
|
#### Queue Management
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Enqueue URL for processing
|
// Enqueue URL for processing
|
||||||
POST /api/queue
|
POST /api/queue
|
||||||
@@ -84,6 +87,7 @@ Events: connection, queue-update, ping
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Push Notifications
|
#### Push Notifications
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Subscribe to push notifications
|
// Subscribe to push notifications
|
||||||
POST /api/notifications/subscribe
|
POST /api/notifications/subscribe
|
||||||
@@ -101,11 +105,11 @@ These endpoints are marked for removal and should not be used in new code:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ DEPRECATED: Synchronous extraction
|
// ❌ DEPRECATED: Synchronous extraction
|
||||||
POST /api/extract
|
POST / api / extract;
|
||||||
// 👉 Use: POST /api/queue
|
// 👉 Use: POST /api/queue
|
||||||
|
|
||||||
// ❌ DEPRECATED: Long-polling progress
|
// ❌ DEPRECATED: Long-polling progress
|
||||||
GET /api/extract-stream
|
GET / api / extract - stream;
|
||||||
// 👉 Use: GET /api/queue/stream
|
// 👉 Use: GET /api/queue/stream
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -167,6 +171,7 @@ interface QueueStatusUpdate {
|
|||||||
### For Frontend Applications
|
### For Frontend Applications
|
||||||
|
|
||||||
1. **Replace Synchronous Calls**
|
1. **Replace Synchronous Calls**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ Old synchronous approach
|
// ❌ Old synchronous approach
|
||||||
const response = await fetch('/api/extract', {
|
const response = await fetch('/api/extract', {
|
||||||
@@ -187,6 +192,7 @@ interface QueueStatusUpdate {
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Add Real-time Updates**
|
2. **Add Real-time Updates**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Setup Server-Sent Events for progress tracking
|
// Setup Server-Sent Events for progress tracking
|
||||||
const eventSource = new EventSource(`/api/queue/stream?itemId=${itemId}`);
|
const eventSource = new EventSource(`/api/queue/stream?itemId=${itemId}`);
|
||||||
@@ -222,6 +228,7 @@ interface QueueStatusUpdate {
|
|||||||
### For Backend Integrations
|
### For Backend Integrations
|
||||||
|
|
||||||
1. **Update API Calls**
|
1. **Update API Calls**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ Old synchronous API
|
# ❌ Old synchronous API
|
||||||
response = requests.post('/api/extract', json={'url': url})
|
response = requests.post('/api/extract', json={'url': url})
|
||||||
@@ -240,6 +247,7 @@ interface QueueStatusUpdate {
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Implement SSE Client** (Python example)
|
2. **Implement SSE Client** (Python example)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import sseclient
|
import sseclient
|
||||||
|
|
||||||
@@ -314,18 +322,21 @@ npm test queue-sse
|
|||||||
## Performance Considerations
|
## Performance Considerations
|
||||||
|
|
||||||
### Before Migration
|
### Before Migration
|
||||||
|
|
||||||
- **Blocking Operations**: Each request blocked a server thread
|
- **Blocking Operations**: Each request blocked a server thread
|
||||||
- **Single Processing**: One extraction at a time
|
- **Single Processing**: One extraction at a time
|
||||||
- **No Progress**: Users waited without feedback
|
- **No Progress**: Users waited without feedback
|
||||||
- **Memory Usage**: High memory usage during long operations
|
- **Memory Usage**: High memory usage during long operations
|
||||||
|
|
||||||
### After Migration
|
### After Migration
|
||||||
|
|
||||||
- **Non-blocking**: Requests return immediately
|
- **Non-blocking**: Requests return immediately
|
||||||
- **Concurrent Processing**: Multiple extractions in parallel
|
- **Concurrent Processing**: Multiple extractions in parallel
|
||||||
- **Real-time Feedback**: Live progress updates
|
- **Real-time Feedback**: Live progress updates
|
||||||
- **Efficient Memory**: Event-driven, minimal memory footprint
|
- **Efficient Memory**: Event-driven, minimal memory footprint
|
||||||
|
|
||||||
### Performance Metrics
|
### Performance Metrics
|
||||||
|
|
||||||
- **Response Time**: 50ms (queue) vs 30-60s (synchronous)
|
- **Response Time**: 50ms (queue) vs 30-60s (synchronous)
|
||||||
- **Throughput**: 2x concurrent processing vs 1x sequential
|
- **Throughput**: 2x concurrent processing vs 1x sequential
|
||||||
- **User Experience**: Immediate feedback vs long waiting
|
- **User Experience**: Immediate feedback vs long waiting
|
||||||
@@ -336,11 +347,13 @@ npm test queue-sse
|
|||||||
If issues arise, the system can be rolled back by:
|
If issues arise, the system can be rolled back by:
|
||||||
|
|
||||||
1. **Disable Queue Processing**
|
1. **Disable Queue Processing**
|
||||||
|
|
||||||
```env
|
```env
|
||||||
QUEUE_PROCESSING_ENABLED=false
|
QUEUE_PROCESSING_ENABLED=false
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Re-enable Legacy Endpoints** (if preserved)
|
2. **Re-enable Legacy Endpoints** (if preserved)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Temporary fallback to synchronous processing
|
// Temporary fallback to synchronous processing
|
||||||
app.post('/api/extract', legacyExtractHandler);
|
app.post('/api/extract', legacyExtractHandler);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
This guide documents SSR-safe patterns and anti-patterns for InstaRecipe development. All developers should follow these guidelines to prevent SSR errors.
|
This guide documents SSR-safe patterns and anti-patterns for InstaRecipe development. All developers should follow these guidelines to prevent SSR errors.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Core Principle](#core-principle)
|
- [Core Principle](#core-principle)
|
||||||
- [Browser API Detection](#browser-api-detection)
|
- [Browser API Detection](#browser-api-detection)
|
||||||
- [Lifecycle Hooks](#lifecycle-hooks)
|
- [Lifecycle Hooks](#lifecycle-hooks)
|
||||||
@@ -18,6 +19,7 @@ This guide documents SSR-safe patterns and anti-patterns for InstaRecipe develop
|
|||||||
**SvelteKit renders components on both the server and client.** Any browser-only APIs must be guarded or used in browser-only contexts.
|
**SvelteKit renders components on both the server and client.** Any browser-only APIs must be guarded or used in browser-only contexts.
|
||||||
|
|
||||||
### Browser-Only APIs (Require Guards)
|
### Browser-Only APIs (Require Guards)
|
||||||
|
|
||||||
- `window.*`
|
- `window.*`
|
||||||
- `document.*`
|
- `document.*`
|
||||||
- `localStorage`, `sessionStorage`
|
- `localStorage`, `sessionStorage`
|
||||||
@@ -72,6 +74,7 @@ if (browser) {
|
|||||||
### `onMount` - Browser-Only Lifecycle
|
### `onMount` - Browser-Only Lifecycle
|
||||||
|
|
||||||
**Use `onMount` for:**
|
**Use `onMount` for:**
|
||||||
|
|
||||||
- Browser API initialization
|
- Browser API initialization
|
||||||
- Timer setup (`setInterval`, `setTimeout`)
|
- Timer setup (`setInterval`, `setTimeout`)
|
||||||
- Event listener registration
|
- Event listener registration
|
||||||
@@ -160,11 +163,13 @@ onMount(() => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**When to use `$effect`:**
|
**When to use `$effect`:**
|
||||||
|
|
||||||
- Synchronizing derived state
|
- Synchronizing derived state
|
||||||
- DOM manipulation (with browser guard)
|
- DOM manipulation (with browser guard)
|
||||||
- Reactive cleanup
|
- Reactive cleanup
|
||||||
|
|
||||||
**When NOT to use `$effect`:**
|
**When NOT to use `$effect`:**
|
||||||
|
|
||||||
- Initialization (use `onMount`)
|
- Initialization (use `onMount`)
|
||||||
- API calls on mount (use `onMount`)
|
- API calls on mount (use `onMount`)
|
||||||
- Timer setup (use `onMount`)
|
- Timer setup (use `onMount`)
|
||||||
@@ -189,11 +194,13 @@ if (browser && eventSource?.readyState === EventSource.OPEN)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**EventSource States:**
|
**EventSource States:**
|
||||||
|
|
||||||
- `EventSource.CONNECTING = 0`
|
- `EventSource.CONNECTING = 0`
|
||||||
- `EventSource.OPEN = 1`
|
- `EventSource.OPEN = 1`
|
||||||
- `EventSource.CLOSED = 2`
|
- `EventSource.CLOSED = 2`
|
||||||
|
|
||||||
**WebSocket States:**
|
**WebSocket States:**
|
||||||
|
|
||||||
- `WebSocket.CONNECTING = 0`
|
- `WebSocket.CONNECTING = 0`
|
||||||
- `WebSocket.OPEN = 1`
|
- `WebSocket.OPEN = 1`
|
||||||
- `WebSocket.CLOSING = 2`
|
- `WebSocket.CLOSING = 2`
|
||||||
@@ -276,6 +283,7 @@ export class PushNotificationManager {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Why it's good:**
|
**Why it's good:**
|
||||||
|
|
||||||
- Guards all browser API access
|
- Guards all browser API access
|
||||||
- Early returns prevent unnecessary code execution during SSR
|
- Early returns prevent unnecessary code execution during SSR
|
||||||
- Defensive programming with null checks
|
- Defensive programming with null checks
|
||||||
@@ -327,6 +335,7 @@ export class PushNotificationManager {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Why it's good:**
|
**Why it's good:**
|
||||||
|
|
||||||
- Uses `onMount` instead of `$effect` for initialization
|
- Uses `onMount` instead of `$effect` for initialization
|
||||||
- Timer setup in browser-only context
|
- Timer setup in browser-only context
|
||||||
- Proper cleanup with return function
|
- Proper cleanup with return function
|
||||||
@@ -408,6 +417,7 @@ Watch server console for errors during build and preview.
|
|||||||
### 2. Check for Hydration Warnings
|
### 2. Check for Hydration Warnings
|
||||||
|
|
||||||
Open browser DevTools console and look for:
|
Open browser DevTools console and look for:
|
||||||
|
|
||||||
- "Hydration failed"
|
- "Hydration failed"
|
||||||
- "The server response doesn't match the client content"
|
- "The server response doesn't match the client content"
|
||||||
|
|
||||||
@@ -422,6 +432,7 @@ grep -r "navigator\." src/routes --include="*.svelte"
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then verify each usage is either:
|
Then verify each usage is either:
|
||||||
|
|
||||||
- In an event handler (safe)
|
- In an event handler (safe)
|
||||||
- In `onMount` (safe)
|
- In `onMount` (safe)
|
||||||
- Guarded with `if (browser)` (safe)
|
- Guarded with `if (browser)` (safe)
|
||||||
|
|||||||
@@ -21,16 +21,14 @@ export default defineConfig(
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: { ...globals.browser, ...globals.node }
|
globals: { ...globals.browser, ...globals.node }
|
||||||
},
|
},
|
||||||
rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
rules: {
|
||||||
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
"no-undef": 'off' }
|
'no-undef': 'off'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: [
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
'**/*.svelte',
|
|
||||||
'**/*.svelte.ts',
|
|
||||||
'**/*.svelte.js'
|
|
||||||
],
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: true,
|
projectService: true,
|
||||||
|
|||||||
@@ -15,20 +15,20 @@ export default defineConfig({
|
|||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:5173',
|
baseURL: 'http://localhost:5173',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry'
|
||||||
},
|
},
|
||||||
|
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] }
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: 'npm run dev',
|
||||||
url: 'http://localhost:5173',
|
url: 'http://localhost:5173',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120000,
|
timeout: 120000
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ async function generateFaviconIco() {
|
|||||||
console.log('✓ All validation checks passed');
|
console.log('✓ All validation checks passed');
|
||||||
}
|
}
|
||||||
|
|
||||||
generateFaviconIco().catch(err => {
|
generateFaviconIco().catch((err) => {
|
||||||
console.error('Error generating favicon.ico:', err);
|
console.error('Error generating favicon.ico:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async function generateFavicon() {
|
|||||||
console.log('✓ All validation checks passed');
|
console.log('✓ All validation checks passed');
|
||||||
}
|
}
|
||||||
|
|
||||||
generateFavicon().catch(err => {
|
generateFavicon().catch((err) => {
|
||||||
console.error('Error generating favicon:', err);
|
console.error('Error generating favicon:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export class PWAInstallManager {
|
|||||||
callback(this.canInstall());
|
callback(this.canInstall());
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
this.listeners = this.listeners.filter(cb => cb !== callback);
|
this.listeners = this.listeners.filter((cb) => cb !== callback);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ export class PWAInstallManager {
|
|||||||
* Notify all listeners of state change
|
* Notify all listeners of state change
|
||||||
*/
|
*/
|
||||||
private notifyListeners(canInstall: boolean): void {
|
private notifyListeners(canInstall: boolean): void {
|
||||||
this.listeners.forEach(callback => {
|
this.listeners.forEach((callback) => {
|
||||||
try {
|
try {
|
||||||
callback(canInstall);
|
callback(canInstall);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class PushNotificationManager {
|
|||||||
callback(this.state); // Send initial state
|
callback(this.state); // Send initial state
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
this.listeners = this.listeners.filter(cb => cb !== callback);
|
this.listeners = this.listeners.filter((cb) => cb !== callback);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,11 +90,8 @@ class PushNotificationManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state.supported = (
|
this.state.supported =
|
||||||
'serviceWorker' in navigator &&
|
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||||
'PushManager' in window &&
|
|
||||||
'Notification' in window
|
|
||||||
);
|
|
||||||
|
|
||||||
this.state.permission = this.state.supported ? Notification.permission : 'denied';
|
this.state.permission = this.state.supported ? Notification.permission : 'denied';
|
||||||
}
|
}
|
||||||
@@ -164,7 +161,7 @@ class PushNotificationManager {
|
|||||||
* Subscribe to push notifications
|
* Subscribe to push notifications
|
||||||
*/
|
*/
|
||||||
async subscribe(): Promise<boolean> {
|
async subscribe(): Promise<boolean> {
|
||||||
if (!await this.requestPermission()) {
|
if (!(await this.requestPermission())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +212,6 @@ class PushNotificationManager {
|
|||||||
|
|
||||||
console.log('[PushManager] Successfully subscribed to push notifications');
|
console.log('[PushManager] Successfully subscribed to push notifications');
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PushManager] Subscription failed:', error);
|
console.error('[PushManager] Subscription failed:', error);
|
||||||
this.state.error = 'Failed to subscribe to notifications';
|
this.state.error = 'Failed to subscribe to notifications';
|
||||||
@@ -265,7 +261,6 @@ class PushNotificationManager {
|
|||||||
|
|
||||||
console.log('[PushManager] Successfully unsubscribed from push notifications');
|
console.log('[PushManager] Successfully unsubscribed from push notifications');
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PushManager] Unsubscription failed:', error);
|
console.error('[PushManager] Unsubscription failed:', error);
|
||||||
this.state.error = 'Failed to unsubscribe from notifications';
|
this.state.error = 'Failed to unsubscribe from notifications';
|
||||||
@@ -331,10 +326,8 @@ class PushNotificationManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Add proper padding
|
// Add proper padding
|
||||||
const padding = '='.repeat((4 - cleanKey.length % 4) % 4);
|
const padding = '='.repeat((4 - (cleanKey.length % 4)) % 4);
|
||||||
const base64 = (cleanKey + padding)
|
const base64 = (cleanKey + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
.replace(/-/g, '+')
|
|
||||||
.replace(/_/g, '/');
|
|
||||||
|
|
||||||
// Validate base64 format before decoding
|
// Validate base64 format before decoding
|
||||||
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
|
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
|
||||||
@@ -351,7 +344,6 @@ class PushNotificationManager {
|
|||||||
|
|
||||||
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
|
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
|
||||||
return outputArray;
|
return outputArray;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey);
|
console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey);
|
||||||
@@ -363,7 +355,7 @@ class PushNotificationManager {
|
|||||||
* Notify all listeners of state change
|
* Notify all listeners of state change
|
||||||
*/
|
*/
|
||||||
private notifyListeners(): void {
|
private notifyListeners(): void {
|
||||||
this.listeners.forEach(callback => {
|
this.listeners.forEach((callback) => {
|
||||||
try {
|
try {
|
||||||
callback({ ...this.state });
|
callback({ ...this.state });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* and coordinates with the main application.
|
* and coordinates with the main application.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { pushState } from "$app/navigation";
|
import { pushState } from '$app/navigation';
|
||||||
|
|
||||||
interface ServiceWorkerMessage {
|
interface ServiceWorkerMessage {
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -29,36 +29,46 @@ export function handleApiError(error: unknown): Response {
|
|||||||
|
|
||||||
// Handle known error types with specific status codes
|
// Handle known error types with specific status codes
|
||||||
if (error instanceof ValidationError) {
|
if (error instanceof ValidationError) {
|
||||||
return json({
|
return json(
|
||||||
|
{
|
||||||
message: error.message,
|
message: error.message,
|
||||||
type: 'validation_error'
|
type: 'validation_error'
|
||||||
}, { status: 400 });
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof NotFoundError) {
|
if (error instanceof NotFoundError) {
|
||||||
return json({
|
return json(
|
||||||
|
{
|
||||||
message: error.message,
|
message: error.message,
|
||||||
type: 'not_found_error'
|
type: 'not_found_error'
|
||||||
}, { status: 404 });
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof ConflictError) {
|
if (error instanceof ConflictError) {
|
||||||
return json({
|
return json(
|
||||||
|
{
|
||||||
message: error.message,
|
message: error.message,
|
||||||
type: 'conflict_error'
|
type: 'conflict_error'
|
||||||
}, { status: 409 });
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle generic errors
|
// Handle generic errors
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||||
|
|
||||||
// Don't expose internal error details in production
|
// Don't expose internal error details in production
|
||||||
const publicMessage = process.env.NODE_ENV === 'production'
|
const publicMessage = process.env.NODE_ENV === 'production' ? 'Internal server error' : message;
|
||||||
? 'Internal server error'
|
|
||||||
: message;
|
|
||||||
|
|
||||||
return json({
|
return json(
|
||||||
|
{
|
||||||
message: publicMessage,
|
message: publicMessage,
|
||||||
type: 'server_error'
|
type: 'server_error'
|
||||||
}, { status: 500 });
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,14 @@ export interface ExtractedContent {
|
|||||||
thumbnail: string | null;
|
thumbnail: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExtractionMethod = 'embedded-json' | 'internal-state' | 'html-section' | 'dom-selector' | 'graphql-api' | 'graphql-intercept' | 'legacy';
|
export type ExtractionMethod =
|
||||||
|
| 'embedded-json'
|
||||||
|
| 'internal-state'
|
||||||
|
| 'html-section'
|
||||||
|
| 'dom-selector'
|
||||||
|
| 'graphql-api'
|
||||||
|
| 'graphql-intercept'
|
||||||
|
| 'legacy';
|
||||||
|
|
||||||
type CaptionCandidate = {
|
type CaptionCandidate = {
|
||||||
element: Element;
|
element: Element;
|
||||||
@@ -192,7 +199,146 @@ function extractShortcode(url: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean extracted text
|
* Recipe keywords used for caption scoring
|
||||||
|
*/
|
||||||
|
const RECIPE_KEYWORDS = [
|
||||||
|
'ingredienti',
|
||||||
|
'procedimento',
|
||||||
|
'preparazione',
|
||||||
|
'ricetta',
|
||||||
|
'recipe',
|
||||||
|
'instructions'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeout configuration constants (in milliseconds)
|
||||||
|
*/
|
||||||
|
const TIMEOUTS = {
|
||||||
|
CONTENT_LOAD: 1500,
|
||||||
|
MORE_BUTTON_VISIBILITY: 1000,
|
||||||
|
CAPTION_EXPANSION: 3000,
|
||||||
|
MORE_BUTTON_VISIBILITY_DOM: 500,
|
||||||
|
MORE_BUTTON_CLICK: 800,
|
||||||
|
PAGE_LOAD: 10000,
|
||||||
|
NETWORK_SETTLE: 2000,
|
||||||
|
ARTICLE_SELECTOR: 5000,
|
||||||
|
GRAPHQL_WAIT: 1000,
|
||||||
|
PAGE_NAVIGATION: 30000,
|
||||||
|
ANTI_DETECTION_MIN: 1000,
|
||||||
|
ANTI_DETECTION_MAX: 3000
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to expand truncated caption by clicking "more" button in HTML section method
|
||||||
|
*/
|
||||||
|
async function tryExpandCaptionInHTMLSection(page: Page): Promise<void> {
|
||||||
|
console.log('[Extractor] Looking for "more" button in primary post container...');
|
||||||
|
try {
|
||||||
|
await page.waitForTimeout(TIMEOUTS.CONTENT_LOAD);
|
||||||
|
|
||||||
|
const mainContainer = page.locator('article, main, [role="main"]').first();
|
||||||
|
const containerExists = (await mainContainer.count()) > 0;
|
||||||
|
|
||||||
|
if (!containerExists) {
|
||||||
|
console.log('[Extractor] No main container found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Extractor] Found main post container, searching for "more" button...');
|
||||||
|
|
||||||
|
const morePatterns = [
|
||||||
|
{
|
||||||
|
locator: mainContainer.locator('span').filter({ hasText: /\.\.\.\s*more/i }),
|
||||||
|
desc: "span with '...more'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locator: mainContainer.locator('span').filter({ hasText: /…\s*more/i }),
|
||||||
|
desc: "span with '… more'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locator: mainContainer.locator('div[role="button"]').filter({ hasText: /more/i }),
|
||||||
|
desc: "button with 'more'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locator: mainContainer.locator('span[role="button"]').filter({ hasText: /more/i }),
|
||||||
|
desc: "span button with 'more'"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of morePatterns) {
|
||||||
|
const count = await pattern.locator.count();
|
||||||
|
console.log(`[Extractor] Checking ${pattern.desc}: found ${count}`);
|
||||||
|
|
||||||
|
if (count === 0) continue;
|
||||||
|
|
||||||
|
const firstMore = pattern.locator.first();
|
||||||
|
try {
|
||||||
|
if (await firstMore.isVisible({ timeout: TIMEOUTS.MORE_BUTTON_VISIBILITY })) {
|
||||||
|
const text = await firstMore.textContent();
|
||||||
|
console.log(`[Extractor] Found visible "more": "${text}"`);
|
||||||
|
await firstMore.click();
|
||||||
|
console.log('[Extractor] Clicked "more" - waiting for expansion...');
|
||||||
|
await page.waitForTimeout(TIMEOUTS.CAPTION_EXPANSION);
|
||||||
|
console.log('[Extractor] Caption expansion complete');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Extractor] ${pattern.desc} not clickable: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Extractor] Finished "more" button expansion attempt');
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Extractor] Error while trying to expand caption: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to expand truncated caption by clicking "more" button in DOM method
|
||||||
|
*/
|
||||||
|
async function tryExpandCaptionInDOM(page: Page): Promise<void> {
|
||||||
|
const moreButtonSelectors = [
|
||||||
|
'article button:has-text("more")',
|
||||||
|
'article button:has-text("More")',
|
||||||
|
'article button:has-text("… more")',
|
||||||
|
'article span[role="button"]:has-text("more")',
|
||||||
|
'article [role="button"]:has-text("more")',
|
||||||
|
'article div[role="button"]:has-text("more")',
|
||||||
|
'xpath=//article//span[contains(text(), "more")]/..',
|
||||||
|
'xpath=//article//button[contains(., "more")]'
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxExpandAttempts = 3;
|
||||||
|
let expandAttempts = 0;
|
||||||
|
|
||||||
|
while (expandAttempts < maxExpandAttempts) {
|
||||||
|
try {
|
||||||
|
let clicked = false;
|
||||||
|
for (const selector of moreButtonSelectors) {
|
||||||
|
try {
|
||||||
|
const button = page.locator(selector).first();
|
||||||
|
if (await button.isVisible({ timeout: TIMEOUTS.MORE_BUTTON_VISIBILITY_DOM })) {
|
||||||
|
await button.click();
|
||||||
|
await page.waitForTimeout(TIMEOUTS.MORE_BUTTON_CLICK);
|
||||||
|
console.log(`[Extractor] Clicked "more" button with selector: ${selector}`);
|
||||||
|
clicked = true;
|
||||||
|
expandAttempts++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Try next selector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clicked) break;
|
||||||
|
} catch (e) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up extracted text - removes HTML tags, decodes entities, cleans whitespace
|
||||||
*/
|
*/
|
||||||
export function cleanText(text: string): string {
|
export function cleanText(text: string): string {
|
||||||
let cleaned = text;
|
let cleaned = text;
|
||||||
@@ -292,7 +438,9 @@ async function extractFromEmbeddedJSON(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try __additionalDataLoaded pattern
|
// Try __additionalDataLoaded pattern
|
||||||
const additionalDataMatch = content.match(/window\.__additionalDataLoaded\([^,]+,\s*(\{.+?\})\);/s);
|
const additionalDataMatch = content.match(
|
||||||
|
/window\.__additionalDataLoaded\([^,]+,\s*(\{.+?\})\);/s
|
||||||
|
);
|
||||||
if (additionalDataMatch) {
|
if (additionalDataMatch) {
|
||||||
console.log(`[Extractor] Found __additionalDataLoaded in script ${i}`);
|
console.log(`[Extractor] Found __additionalDataLoaded in script ${i}`);
|
||||||
try {
|
try {
|
||||||
@@ -309,7 +457,9 @@ async function extractFromEmbeddedJSON(
|
|||||||
|
|
||||||
// Try to find any large JSON with caption data (new Instagram format)
|
// Try to find any large JSON with caption data (new Instagram format)
|
||||||
if ((content.includes('"caption"') || content.includes('"text"')) && content.length > 10000) {
|
if ((content.includes('"caption"') || content.includes('"text"')) && content.length > 10000) {
|
||||||
console.log(`[Extractor] Attempting to extract from large JSON in script ${i} (length: ${content.length})`);
|
console.log(
|
||||||
|
`[Extractor] Attempting to extract from large JSON in script ${i} (length: ${content.length})`
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
// Try to parse as direct JSON
|
// Try to parse as direct JSON
|
||||||
const jsonData = JSON.parse(content);
|
const jsonData = JSON.parse(content);
|
||||||
@@ -317,7 +467,9 @@ async function extractFromEmbeddedJSON(
|
|||||||
// Try deep search first
|
// Try deep search first
|
||||||
const deepResult = deepSearchForCaption(jsonData);
|
const deepResult = deepSearchForCaption(jsonData);
|
||||||
if (deepResult && deepResult.bodyText && deepResult.bodyText.length > 130) {
|
if (deepResult && deepResult.bodyText && deepResult.bodyText.length > 130) {
|
||||||
console.log(`[Extractor] Deep search in JSON found caption: ${deepResult.bodyText.length} chars`);
|
console.log(
|
||||||
|
`[Extractor] Deep search in JSON found caption: ${deepResult.bodyText.length} chars`
|
||||||
|
);
|
||||||
const thumbnail = await extractThumbnailStealth(page, progressCallback);
|
const thumbnail = await extractThumbnailStealth(page, progressCallback);
|
||||||
return { ...deepResult, thumbnail };
|
return { ...deepResult, thumbnail };
|
||||||
}
|
}
|
||||||
@@ -325,7 +477,9 @@ async function extractFromEmbeddedJSON(
|
|||||||
// Try standard parsing
|
// Try standard parsing
|
||||||
const result = parseInstagramData(jsonData);
|
const result = parseInstagramData(jsonData);
|
||||||
if (result && result.bodyText && result.bodyText.length > 130) {
|
if (result && result.bodyText && result.bodyText.length > 130) {
|
||||||
console.log(`[Extractor] Successfully extracted from JSON, text length: ${result.bodyText.length}`);
|
console.log(
|
||||||
|
`[Extractor] Successfully extracted from JSON, text length: ${result.bodyText.length}`
|
||||||
|
);
|
||||||
const thumbnail = await extractThumbnailStealth(page, progressCallback);
|
const thumbnail = await extractThumbnailStealth(page, progressCallback);
|
||||||
return { ...result, thumbnail };
|
return { ...result, thumbnail };
|
||||||
}
|
}
|
||||||
@@ -336,7 +490,7 @@ async function extractFromEmbeddedJSON(
|
|||||||
const patterns = [
|
const patterns = [
|
||||||
/"caption"\s*:\s*\{\s*"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"/, // Escaped quotes
|
/"caption"\s*:\s*\{\s*"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"/, // Escaped quotes
|
||||||
/"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"\s*,?\s*"pk"/, // text field near pk
|
/"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"\s*,?\s*"pk"/, // text field near pk
|
||||||
/"edge_media_to_caption"\s*:\s*\{\s*"edges"\s*:\s*\[\s*\{\s*"node"\s*:\s*\{\s*"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"/,
|
/"edge_media_to_caption"\s*:\s*\{\s*"edges"\s*:\s*\[\s*\{\s*"node"\s*:\s*\{\s*"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"/
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
@@ -347,11 +501,15 @@ async function extractFromEmbeddedJSON(
|
|||||||
const captionText = rawText
|
const captionText = rawText
|
||||||
.replace(/\\n/g, '\n')
|
.replace(/\\n/g, '\n')
|
||||||
.replace(/\\"/g, '"')
|
.replace(/\\"/g, '"')
|
||||||
.replace(/\\u([0-9a-fA-F]{4})/g, (_, code) => String.fromCharCode(parseInt(code, 16)))
|
.replace(/\\u([0-9a-fA-F]{4})/g, (_, code) =>
|
||||||
|
String.fromCharCode(parseInt(code, 16))
|
||||||
|
)
|
||||||
.replace(/\\\\/g, '\\');
|
.replace(/\\\\/g, '\\');
|
||||||
|
|
||||||
if (captionText.length > 130) {
|
if (captionText.length > 130) {
|
||||||
console.log(`[Extractor] Extracted caption from regex pattern, length: ${captionText.length}`);
|
console.log(
|
||||||
|
`[Extractor] Extracted caption from regex pattern, length: ${captionText.length}`
|
||||||
|
);
|
||||||
const thumbnail = await extractThumbnailStealth(page, progressCallback);
|
const thumbnail = await extractThumbnailStealth(page, progressCallback);
|
||||||
return { bodyText: cleanText(captionText), thumbnail };
|
return { bodyText: cleanText(captionText), thumbnail };
|
||||||
}
|
}
|
||||||
@@ -448,7 +606,9 @@ export async function extractFromHTMLSection(
|
|||||||
const currentShortcode = extractShortcode(currentUrl);
|
const currentShortcode = extractShortcode(currentUrl);
|
||||||
|
|
||||||
console.log(`[Extractor] Current page URL: ${currentUrl}`);
|
console.log(`[Extractor] Current page URL: ${currentUrl}`);
|
||||||
console.log(`[Extractor] Target shortcode: ${targetShortcode}, Current shortcode: ${currentShortcode}`);
|
console.log(
|
||||||
|
`[Extractor] Target shortcode: ${targetShortcode}, Current shortcode: ${currentShortcode}`
|
||||||
|
);
|
||||||
|
|
||||||
if (targetShortcode && currentShortcode !== targetShortcode) {
|
if (targetShortcode && currentShortcode !== targetShortcode) {
|
||||||
console.log(`[Extractor] URL mismatch: expected ${targetShortcode}, got ${currentShortcode}`);
|
console.log(`[Extractor] URL mismatch: expected ${targetShortcode}, got ${currentShortcode}`);
|
||||||
@@ -458,62 +618,13 @@ export async function extractFromHTMLSection(
|
|||||||
console.log(`[Extractor] Confirmed on correct post: ${currentShortcode}`);
|
console.log(`[Extractor] Confirmed on correct post: ${currentShortcode}`);
|
||||||
|
|
||||||
// Wait for network to settle
|
// Wait for network to settle
|
||||||
await page.waitForLoadState('domcontentloaded', { timeout: 10000 });
|
await page.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.PAGE_LOAD });
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(TIMEOUTS.NETWORK_SETTLE);
|
||||||
|
|
||||||
//Try to expand truncated caption by clicking "more" button
|
// Try to expand truncated caption by clicking "more" button
|
||||||
// STRATEGY: Since we're already on the correct page (URL validated above),
|
// STRATEGY: Since we're already on the correct page (URL validated above),
|
||||||
// the FIRST article/main post container should be our target post.
|
// the FIRST article/main post container should be our target post.
|
||||||
// Instagram uses JS routing so links don't have shortcodes in hrefs.
|
await tryExpandCaptionInHTMLSection(page);
|
||||||
console.log('[Extractor] Looking for "more" button in primary post container...');
|
|
||||||
try {
|
|
||||||
// Wait for content to load
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
// Find the MAIN post container - should be the first article or main content area
|
|
||||||
const mainContainer = page.locator('article, main, [role="main"]').first();
|
|
||||||
const containerExists = await mainContainer.count() > 0;
|
|
||||||
|
|
||||||
if (containerExists) {
|
|
||||||
console.log('[Extractor] Found main post container, searching for "more" button...');
|
|
||||||
|
|
||||||
// Try different patterns for the "more" button within the main container
|
|
||||||
const morePatterns = [
|
|
||||||
{ locator: mainContainer.locator('span').filter({ hasText: /\.\.\.\s*more/i }), desc: "span with '...more'" },
|
|
||||||
{ locator: mainContainer.locator('span').filter({ hasText: /…\s*more/i }), desc: "span with '… more'" },
|
|
||||||
{ locator: mainContainer.locator('div[role="button"]').filter({ hasText: /more/i }), desc: "button with 'more'" },
|
|
||||||
{ locator: mainContainer.locator('span[role="button"]').filter({ hasText: /more/i }), desc: "span button with 'more'" }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of morePatterns) {
|
|
||||||
const count = await pattern.locator.count();
|
|
||||||
console.log(`[Extractor] Checking ${pattern.desc}: found ${count}`);
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
const firstMore = pattern.locator.first();
|
|
||||||
try {
|
|
||||||
if (await firstMore.isVisible({ timeout: 1000 })) {
|
|
||||||
const text = await firstMore.textContent();
|
|
||||||
console.log(`[Extractor] Found visible "more": "${text}"`);
|
|
||||||
await firstMore.click();
|
|
||||||
console.log('[Extractor] Clicked "more" - waiting for expansion...');
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
console.log('[Extractor] Caption expansion complete');
|
|
||||||
break; // Success!
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[Extractor] ${pattern.desc} not clickable: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('[Extractor] No main container found');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Extractor] Finished "more" button expansion attempt');
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[Extractor] Error while trying to expand caption: ${e}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Extractor] Extracting caption using intelligent span detection...');
|
console.log('[Extractor] Extracting caption using intelligent span detection...');
|
||||||
|
|
||||||
@@ -538,9 +649,10 @@ export async function extractFromHTMLSection(
|
|||||||
// If we found links to the post, search for spans within those link ancestors
|
// If we found links to the post, search for spans within those link ancestors
|
||||||
const searchRoots: Element[] = [];
|
const searchRoots: Element[] = [];
|
||||||
if (postLinks.length > 0) {
|
if (postLinks.length > 0) {
|
||||||
postLinks.forEach(link => {
|
postLinks.forEach((link) => {
|
||||||
// Get the article or section container for this post
|
// Get the article or section container for this post
|
||||||
let container = link.closest('article') || link.closest('section') || link.closest('[role="main"]');
|
let container =
|
||||||
|
link.closest('article') || link.closest('section') || link.closest('[role="main"]');
|
||||||
if (container && !searchRoots.includes(container)) {
|
if (container && !searchRoots.includes(container)) {
|
||||||
searchRoots.push(container);
|
searchRoots.push(container);
|
||||||
console.log(`[Extractor] Found container for target post`);
|
console.log(`[Extractor] Found container for target post`);
|
||||||
@@ -555,8 +667,8 @@ export async function extractFromHTMLSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const spans: HTMLElement[] = [];
|
const spans: HTMLElement[] = [];
|
||||||
searchRoots.forEach(root => {
|
searchRoots.forEach((root) => {
|
||||||
root.querySelectorAll('span').forEach(span => spans.push(span as HTMLElement));
|
root.querySelectorAll('span').forEach((span) => spans.push(span as HTMLElement));
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Extractor] Searching ${spans.length} spans for recipe content`);
|
console.log(`[Extractor] Searching ${spans.length} spans for recipe content`);
|
||||||
@@ -584,7 +696,7 @@ export async function extractFromHTMLSection(
|
|||||||
score += brCount * 100; // Massive weight for line breaks
|
score += brCount * 100; // Massive weight for line breaks
|
||||||
|
|
||||||
// Check for recipe keywords (strong indicator)
|
// Check for recipe keywords (strong indicator)
|
||||||
const hasKeywords = recipeKeywords.some(keyword => text.includes(keyword));
|
const hasKeywords = recipeKeywords.some((keyword) => text.includes(keyword));
|
||||||
if (hasKeywords) {
|
if (hasKeywords) {
|
||||||
score += 500; // Huge boost for recipe keywords
|
score += 500; // Huge boost for recipe keywords
|
||||||
}
|
}
|
||||||
@@ -616,7 +728,9 @@ export async function extractFromHTMLSection(
|
|||||||
|
|
||||||
// Update best candidate
|
// Update best candidate
|
||||||
if (score > 0 && (!bestCandidate || score > bestCandidate.score)) {
|
if (score > 0 && (!bestCandidate || score > bestCandidate.score)) {
|
||||||
console.log(`[Extractor] New best: score=${score}, len=${text.length}, br=${brCount}, links=${linkCount}, preview="${text.substring(0, 80)}..."`);
|
console.log(
|
||||||
|
`[Extractor] New best: score=${score}, len=${text.length}, br=${brCount}, links=${linkCount}, preview="${text.substring(0, 80)}..."`
|
||||||
|
);
|
||||||
bestCandidate = {
|
bestCandidate = {
|
||||||
element: span,
|
element: span,
|
||||||
text: span.textContent || '',
|
text: span.textContent || '',
|
||||||
@@ -638,7 +752,9 @@ export async function extractFromHTMLSection(
|
|||||||
// Explicit type assertion (safe after null guard)
|
// Explicit type assertion (safe after null guard)
|
||||||
const candidate: CaptionCandidate = bestCandidate;
|
const candidate: CaptionCandidate = bestCandidate;
|
||||||
|
|
||||||
console.log(`[Extractor] Final caption candidate: score=${candidate.score}, length=${candidate.text.length}`);
|
console.log(
|
||||||
|
`[Extractor] Final caption candidate: score=${candidate.score}, length=${candidate.text.length}`
|
||||||
|
);
|
||||||
|
|
||||||
// Extract text from the best candidate
|
// Extract text from the best candidate
|
||||||
// Use innerHTML to preserve <br> tags, which will be converted to newlines in cleanText
|
// Use innerHTML to preserve <br> tags, which will be converted to newlines in cleanText
|
||||||
@@ -698,15 +814,15 @@ export async function extractFromDOM(
|
|||||||
try {
|
try {
|
||||||
// Give Instagram more time to load dynamic content
|
// Give Instagram more time to load dynamic content
|
||||||
console.log('[Extractor] Waiting for network idle...');
|
console.log('[Extractor] Waiting for network idle...');
|
||||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
|
await page.waitForLoadState('networkidle', { timeout: TIMEOUTS.PAGE_LOAD }).catch(() => {
|
||||||
console.log('[Extractor] Network idle timeout, continuing anyway');
|
console.log('[Extractor] Network idle timeout, continuing anyway');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to wait for article content
|
// Try to wait for article content
|
||||||
await page.waitForSelector('article', { timeout: 5000 }).catch(() => {});
|
await page.waitForSelector('article', { timeout: TIMEOUTS.ARTICLE_SELECTOR }).catch(() => {});
|
||||||
|
|
||||||
// Additional wait for dynamic content
|
// Additional wait for dynamic content
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(TIMEOUTS.NETWORK_SETTLE);
|
||||||
|
|
||||||
// Try to intercept GraphQL responses
|
// Try to intercept GraphQL responses
|
||||||
let graphqlCaption: string | null = null;
|
let graphqlCaption: string | null = null;
|
||||||
@@ -715,11 +831,12 @@ export async function extractFromDOM(
|
|||||||
if (url.includes('graphql') || url.includes('api/v1')) {
|
if (url.includes('graphql') || url.includes('api/v1')) {
|
||||||
try {
|
try {
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
// Try to find caption in the response
|
|
||||||
const captionData = extractCaptionFromGraphQL(json);
|
const captionData = extractCaptionFromGraphQL(json);
|
||||||
if (captionData && captionData.length > 130) {
|
if (captionData && captionData.length > 130) {
|
||||||
graphqlCaption = captionData;
|
graphqlCaption = captionData;
|
||||||
console.log(`[Extractor] Intercepted GraphQL response with ${captionData.length} chars`);
|
console.log(
|
||||||
|
`[Extractor] Intercepted GraphQL response with ${captionData.length} chars`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not JSON or parsing failed
|
// Not JSON or parsing failed
|
||||||
@@ -727,54 +844,15 @@ export async function extractFromDOM(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait a bit for any GraphQL requests to complete
|
await page.waitForTimeout(TIMEOUTS.GRAPHQL_WAIT);
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
if (graphqlCaption) {
|
if (graphqlCaption) {
|
||||||
const thumbnail = await extractThumbnailStealth(page, progressCallback);
|
const thumbnail = await extractThumbnailStealth(page, progressCallback);
|
||||||
return { bodyText: cleanText(graphqlCaption), thumbnail };
|
return { bodyText: cleanText(graphqlCaption), thumbnail };
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, try to expand truncated captions by clicking "more" button
|
// Try to expand truncated captions by clicking "more" button
|
||||||
// Try multiple times with different selectors
|
await tryExpandCaptionInDOM(page);
|
||||||
let expandAttempts = 0;
|
|
||||||
const maxExpandAttempts = 3;
|
|
||||||
|
|
||||||
while (expandAttempts < maxExpandAttempts) {
|
|
||||||
try {
|
|
||||||
const moreButtonSelectors = [
|
|
||||||
'article button:has-text("more")',
|
|
||||||
'article button:has-text("More")',
|
|
||||||
'article button:has-text("… more")',
|
|
||||||
'article span[role="button"]:has-text("more")',
|
|
||||||
'article [role="button"]:has-text("more")',
|
|
||||||
'article div[role="button"]:has-text("more")',
|
|
||||||
'xpath=//article//span[contains(text(), "more")]/..',
|
|
||||||
'xpath=//article//button[contains(., "more")]'
|
|
||||||
];
|
|
||||||
|
|
||||||
let clicked = false;
|
|
||||||
for (const selector of moreButtonSelectors) {
|
|
||||||
try {
|
|
||||||
const button = page.locator(selector).first();
|
|
||||||
if (await button.isVisible({ timeout: 500 })) {
|
|
||||||
await button.click();
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
console.log(`[Extractor] Clicked "more" button with selector: ${selector}`);
|
|
||||||
clicked = true;
|
|
||||||
expandAttempts++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Try next selector
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clicked) break; // No more buttons found
|
|
||||||
} catch (e) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const captionText = await page.evaluate(() => {
|
const captionText = await page.evaluate(() => {
|
||||||
// First check og:description for comparison
|
// First check og:description for comparison
|
||||||
@@ -787,7 +865,9 @@ export async function extractFromDOM(
|
|||||||
|
|
||||||
// SMART APPROACH: Find the truncated text first, then look for full version nearby
|
// SMART APPROACH: Find the truncated text first, then look for full version nearby
|
||||||
// Look for text that ends with "..." or "… more"
|
// Look for text that ends with "..." or "… more"
|
||||||
const allSpans = Array.from(document.querySelectorAll('article span, article div, article h1'));
|
const allSpans = Array.from(
|
||||||
|
document.querySelectorAll('article span, article div, article h1')
|
||||||
|
);
|
||||||
|
|
||||||
let longestText = '';
|
let longestText = '';
|
||||||
let matchedElement = null;
|
let matchedElement = null;
|
||||||
@@ -809,9 +889,12 @@ export async function extractFromDOM(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Look in data attributes
|
// Strategy 2: Look in data attributes
|
||||||
const elementsWithData = Array.from(document.querySelectorAll('[data-caption], [data-text], [data-content]'));
|
const elementsWithData = Array.from(
|
||||||
|
document.querySelectorAll('[data-caption], [data-text], [data-content]')
|
||||||
|
);
|
||||||
for (const el of elementsWithData) {
|
for (const el of elementsWithData) {
|
||||||
const dataCaption = el.getAttribute('data-caption') ||
|
const dataCaption =
|
||||||
|
el.getAttribute('data-caption') ||
|
||||||
el.getAttribute('data-text') ||
|
el.getAttribute('data-text') ||
|
||||||
el.getAttribute('data-content');
|
el.getAttribute('data-content');
|
||||||
if (dataCaption && dataCaption.length > longestText.length) {
|
if (dataCaption && dataCaption.length > longestText.length) {
|
||||||
@@ -821,7 +904,11 @@ export async function extractFromDOM(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Look for hidden/collapsed content
|
// Strategy 3: Look for hidden/collapsed content
|
||||||
const hiddenElements = Array.from(document.querySelectorAll('[style*="display: none"], [style*="display:none"], .collapsed, [aria-hidden="true"]'));
|
const hiddenElements = Array.from(
|
||||||
|
document.querySelectorAll(
|
||||||
|
'[style*="display: none"], [style*="display:none"], .collapsed, [aria-hidden="true"]'
|
||||||
|
)
|
||||||
|
);
|
||||||
for (const el of hiddenElements) {
|
for (const el of hiddenElements) {
|
||||||
const text = el.textContent?.trim() || '';
|
const text = el.textContent?.trim() || '';
|
||||||
if (text.length > longestText.length && text.length > 200) {
|
if (text.length > longestText.length && text.length > 200) {
|
||||||
@@ -838,7 +925,9 @@ export async function extractFromDOM(
|
|||||||
const parentText = parent.textContent?.trim() || '';
|
const parentText = parent.textContent?.trim() || '';
|
||||||
if (parentText.length > longestText.length) {
|
if (parentText.length > longestText.length) {
|
||||||
longestText = parentText;
|
longestText = parentText;
|
||||||
console.log(`[Extractor] Found fuller text in parent element: ${parentText.length} chars`);
|
console.log(
|
||||||
|
`[Extractor] Found fuller text in parent element: ${parentText.length} chars`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,7 +953,10 @@ export async function extractFromDOM(
|
|||||||
// Fallback to og:description
|
// Fallback to og:description
|
||||||
if (metaDesc) {
|
if (metaDesc) {
|
||||||
const content = ogContent;
|
const content = ogContent;
|
||||||
const cleanedContent = content.replace(/^\d+K?\s+likes,\s+\d+\s+comments\s+-\s+[\w.]+\s+on\s+[^:]+:\s*["']?/, '');
|
const cleanedContent = content.replace(
|
||||||
|
/^\d+K?\s+likes,\s+\d+\s+comments\s+-\s+[\w.]+\s+on\s+[^:]+:\s*["']?/,
|
||||||
|
''
|
||||||
|
);
|
||||||
console.log('[Extractor] DOM selector fallback: og:description (with metadata cleanup)');
|
console.log('[Extractor] DOM selector fallback: og:description (with metadata cleanup)');
|
||||||
return cleanedContent;
|
return cleanedContent;
|
||||||
}
|
}
|
||||||
@@ -1021,11 +1113,15 @@ async function extractFromInternalState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result && result.bodyText && result.bodyText.length > 130) {
|
if (result && result.bodyText && result.bodyText.length > 130) {
|
||||||
console.log(`[Extractor] Successfully extracted from ${stateData.key}, length: ${result.bodyText.length}`);
|
console.log(
|
||||||
|
`[Extractor] Successfully extracted from ${stateData.key}, length: ${result.bodyText.length}`
|
||||||
|
);
|
||||||
const thumbnail = await extractThumbnailStealth(page, progressCallback);
|
const thumbnail = await extractThumbnailStealth(page, progressCallback);
|
||||||
return { ...result, thumbnail };
|
return { ...result, thumbnail };
|
||||||
} else if (result?.bodyText) {
|
} else if (result?.bodyText) {
|
||||||
console.log(`[Extractor] Found text in ${stateData.key} but it's truncated (${result.bodyText.length} chars)`);
|
console.log(
|
||||||
|
`[Extractor] Found text in ${stateData.key} but it's truncated (${result.bodyText.length} chars)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[Extractor] Failed to parse ${stateData.key}:`, e);
|
console.log(`[Extractor] Failed to parse ${stateData.key}:`, e);
|
||||||
@@ -1042,7 +1138,11 @@ async function extractFromInternalState(
|
|||||||
/**
|
/**
|
||||||
* Deep search for caption text in any nested object structure
|
* Deep search for caption text in any nested object structure
|
||||||
*/
|
*/
|
||||||
function deepSearchForCaption(obj: any, maxDepth = 10, currentDepth = 0): Omit<ExtractedContent, 'thumbnail'> | null {
|
function deepSearchForCaption(
|
||||||
|
obj: any,
|
||||||
|
maxDepth = 10,
|
||||||
|
currentDepth = 0
|
||||||
|
): Omit<ExtractedContent, 'thumbnail'> | null {
|
||||||
if (currentDepth > maxDepth || !obj || typeof obj !== 'object') {
|
if (currentDepth > maxDepth || !obj || typeof obj !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1208,7 +1308,8 @@ export async function extractTextAndThumbnail(
|
|||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
return withRetry(async () => {
|
return withRetry(
|
||||||
|
async () => {
|
||||||
const authPath = resolveAuthPath();
|
const authPath = resolveAuthPath();
|
||||||
const context = await createBrowserContext(authPath);
|
const context = await createBrowserContext(authPath);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
@@ -1227,13 +1328,19 @@ export async function extractTextAndThumbnail(
|
|||||||
page.on('response', async (response) => {
|
page.on('response', async (response) => {
|
||||||
try {
|
try {
|
||||||
const responseUrl = response.url();
|
const responseUrl = response.url();
|
||||||
if (responseUrl.includes('graphql') || responseUrl.includes('api/v1') || responseUrl.includes('/web/')) {
|
if (
|
||||||
|
responseUrl.includes('graphql') ||
|
||||||
|
responseUrl.includes('api/v1') ||
|
||||||
|
responseUrl.includes('/web/')
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const captionData = extractCaptionFromGraphQL(json, expectedShortcode ?? undefined);
|
const captionData = extractCaptionFromGraphQL(json, expectedShortcode ?? undefined);
|
||||||
if (captionData && captionData.length > 130) {
|
if (captionData && captionData.length > 130) {
|
||||||
interceptedCaption = captionData;
|
interceptedCaption = captionData;
|
||||||
console.log(`[Extractor] ✓ Intercepted GraphQL with full caption: ${captionData.length} chars (shortcode verified)`);
|
console.log(
|
||||||
|
`[Extractor] ✓ Intercepted GraphQL with full caption: ${captionData.length} chars (shortcode verified)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not JSON or parse error, skip
|
// Not JSON or parse error, skip
|
||||||
@@ -1309,7 +1416,10 @@ export async function extractTextAndThumbnail(
|
|||||||
await page.close();
|
await page.close();
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
}, DEFAULT_RETRY_CONFIG, onProgress);
|
},
|
||||||
|
DEFAULT_RETRY_CONFIG,
|
||||||
|
onProgress
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -131,15 +131,19 @@ class PushNotificationService {
|
|||||||
},
|
},
|
||||||
payload,
|
payload,
|
||||||
{
|
{
|
||||||
TTL: 60 * 60 * 24, // 24 hours
|
TTL: 60 * 60 * 24 // 24 hours
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[PushService] ✓ Sent notification to ${subscription.endpoint.substring(0, 50)}...`);
|
console.log(
|
||||||
|
`[PushService] ✓ Sent notification to ${subscription.endpoint.substring(0, 50)}...`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Check if subscription is expired/invalid
|
// Check if subscription is expired/invalid
|
||||||
if ((error as any).statusCode === 410) {
|
if ((error as any).statusCode === 410) {
|
||||||
console.warn(`[PushService] Subscription expired: ${subscription.endpoint.substring(0, 50)}...`);
|
console.warn(
|
||||||
|
`[PushService] Subscription expired: ${subscription.endpoint.substring(0, 50)}...`
|
||||||
|
);
|
||||||
throw new Error('Subscription expired');
|
throw new Error('Subscription expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ const RecipeSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
servings: z.number().nullable(),
|
servings: z.number().nullable(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
ingredients: z.array(
|
ingredients: z
|
||||||
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
item: z.string(),
|
item: z.string(),
|
||||||
amount: z.string(),
|
amount: z.string(),
|
||||||
unit: z.string()
|
unit: z.string()
|
||||||
})
|
})
|
||||||
).nullable(),
|
)
|
||||||
|
.nullable(),
|
||||||
steps: z.array(z.string()).nullable(),
|
steps: z.array(z.string()).nullable(),
|
||||||
image: z.string().nullable().optional()
|
image: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
@@ -59,9 +61,9 @@ export async function detectRecipe(text: string): Promise<boolean> {
|
|||||||
|
|
||||||
// Check if this is a model-related error
|
// Check if this is a model-related error
|
||||||
const errorMessage = (e as Error).message || '';
|
const errorMessage = (e as Error).message || '';
|
||||||
const isModelError = errorMessage.includes('400') &&
|
const isModelError =
|
||||||
(errorMessage.toLowerCase().includes('model') ||
|
errorMessage.includes('400') &&
|
||||||
errorMessage.toLowerCase().includes('load'));
|
(errorMessage.toLowerCase().includes('model') || errorMessage.toLowerCase().includes('load'));
|
||||||
|
|
||||||
if (isModelError) {
|
if (isModelError) {
|
||||||
const { model } = createLLM();
|
const { model } = createLLM();
|
||||||
@@ -116,9 +118,9 @@ export async function parseRecipe(text: string): Promise<Recipe> {
|
|||||||
|
|
||||||
// Check if this is a model-related error
|
// Check if this is a model-related error
|
||||||
const errorMessage = (e as Error).message || '';
|
const errorMessage = (e as Error).message || '';
|
||||||
const isModelError = errorMessage.includes('400') &&
|
const isModelError =
|
||||||
(errorMessage.toLowerCase().includes('model') ||
|
errorMessage.includes('400') &&
|
||||||
errorMessage.toLowerCase().includes('load'));
|
(errorMessage.toLowerCase().includes('model') || errorMessage.toLowerCase().includes('load'));
|
||||||
|
|
||||||
if (isModelError) {
|
if (isModelError) {
|
||||||
const { model } = createLLM();
|
const { model } = createLLM();
|
||||||
@@ -129,8 +131,10 @@ export async function parseRecipe(text: string): Promise<Recipe> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If structured output fails, try standard completion
|
// If structured output fails, try standard completion
|
||||||
if ((e as any).message?.includes('response_format') ||
|
if (
|
||||||
(e as any).message?.includes('structured output')) {
|
(e as any).message?.includes('response_format') ||
|
||||||
|
(e as any).message?.includes('structured output')
|
||||||
|
) {
|
||||||
console.warn('[LLM] Falling back to standard completion');
|
console.warn('[LLM] Falling back to standard completion');
|
||||||
return await parseRecipeWithStandardCompletion(text);
|
return await parseRecipeWithStandardCompletion(text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,11 +142,7 @@ export class QueueManager {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
updateStatus(
|
updateStatus(itemId: string, status: QueueItemStatus, data?: any): void {
|
||||||
itemId: string,
|
|
||||||
status: QueueItemStatus,
|
|
||||||
data?: any
|
|
||||||
): void {
|
|
||||||
const item = this.items.get(itemId);
|
const item = this.items.get(itemId);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
@@ -163,7 +159,7 @@ export class QueueManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update phases array
|
// Update phases array
|
||||||
const phaseIndex = item.phases.findIndex(p => p.name === data.phase);
|
const phaseIndex = item.phases.findIndex((p) => p.name === data.phase);
|
||||||
if (phaseIndex >= 0) {
|
if (phaseIndex >= 0) {
|
||||||
// Mark previous phases as completed
|
// Mark previous phases as completed
|
||||||
for (let i = 0; i < phaseIndex; i++) {
|
for (let i = 0; i < phaseIndex; i++) {
|
||||||
@@ -181,7 +177,7 @@ export class QueueManager {
|
|||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
item.completedAt = now;
|
item.completedAt = now;
|
||||||
// Mark all phases as completed
|
// Mark all phases as completed
|
||||||
item.phases.forEach(phase => {
|
item.phases.forEach((phase) => {
|
||||||
if (phase.status !== 'completed') {
|
if (phase.status !== 'completed') {
|
||||||
phase.status = 'completed';
|
phase.status = 'completed';
|
||||||
phase.completedAt = now;
|
phase.completedAt = now;
|
||||||
@@ -193,7 +189,7 @@ export class QueueManager {
|
|||||||
item.completedAt = now;
|
item.completedAt = now;
|
||||||
// Mark current phase as error
|
// Mark current phase as error
|
||||||
if (item.currentPhase) {
|
if (item.currentPhase) {
|
||||||
const phaseIndex = item.phases.findIndex(p => p.name === item.currentPhase);
|
const phaseIndex = item.phases.findIndex((p) => p.name === item.currentPhase);
|
||||||
if (phaseIndex >= 0) {
|
if (phaseIndex >= 0) {
|
||||||
item.phases[phaseIndex].status = 'error';
|
item.phases[phaseIndex].status = 'error';
|
||||||
item.phases[phaseIndex].error = data?.error?.message;
|
item.phases[phaseIndex].error = data?.error?.message;
|
||||||
@@ -202,7 +198,12 @@ export class QueueManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wrap results in results object
|
// Wrap results in results object
|
||||||
if (data?.extractedText || data?.thumbnail !== undefined || data?.recipe || data?.tandoorRecipeId) {
|
if (
|
||||||
|
data?.extractedText ||
|
||||||
|
data?.thumbnail !== undefined ||
|
||||||
|
data?.recipe ||
|
||||||
|
data?.tandoorRecipeId
|
||||||
|
) {
|
||||||
if (!item.results) {
|
if (!item.results) {
|
||||||
item.results = {};
|
item.results = {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,12 +115,15 @@ export class QueueProcessor {
|
|||||||
if (!item) break;
|
if (!item) break;
|
||||||
|
|
||||||
this.activeWorkers++;
|
this.activeWorkers++;
|
||||||
console.log(`[QueueProcessor] Starting item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`);
|
console.log(
|
||||||
|
`[QueueProcessor] Starting item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`
|
||||||
|
);
|
||||||
|
|
||||||
this.processItem(item)
|
this.processItem(item).finally(() => {
|
||||||
.finally(() => {
|
|
||||||
this.activeWorkers--;
|
this.activeWorkers--;
|
||||||
console.log(`[QueueProcessor] Finished item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`);
|
console.log(
|
||||||
|
`[QueueProcessor] Finished item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`
|
||||||
|
);
|
||||||
// Try to process next item immediately
|
// Try to process next item immediately
|
||||||
setTimeout(() => this.processNextBatch(), 0);
|
setTimeout(() => this.processNextBatch(), 0);
|
||||||
});
|
});
|
||||||
@@ -164,7 +167,6 @@ export class QueueProcessor {
|
|||||||
|
|
||||||
// Send push notification
|
// Send push notification
|
||||||
await this.sendPushNotification(item, 'success');
|
await this.sendPushNotification(item, 'success');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
const recoverable = this.isRecoverableError(error);
|
const recoverable = this.isRecoverableError(error);
|
||||||
@@ -393,7 +395,7 @@ export class QueueProcessor {
|
|||||||
'fetch failed'
|
'fetch failed'
|
||||||
];
|
];
|
||||||
|
|
||||||
return recoverablePatterns.some(pattern => message.includes(pattern));
|
return recoverablePatterns.some((pattern) => message.includes(pattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export const queueConfig = {
|
|||||||
|
|
||||||
/** Web Push notification settings */
|
/** Web Push notification settings */
|
||||||
push: {
|
push: {
|
||||||
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
|
vapidPublicKey:
|
||||||
|
env.VAPID_PUBLIC_KEY ||
|
||||||
|
'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
|
||||||
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
|
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
|
||||||
vapidEmail: env.VAPID_EMAIL || 'mailto:admin@example.com'
|
vapidEmail: env.VAPID_EMAIL || 'mailto:admin@example.com'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,7 @@ import type { ProgressEvent } from '$lib/server/extraction';
|
|||||||
* - unhealthy: Recoverable error occurred, can be retried
|
* - unhealthy: Recoverable error occurred, can be retried
|
||||||
* - error: Non-recoverable error occurred
|
* - error: Non-recoverable error occurred
|
||||||
*/
|
*/
|
||||||
export type QueueItemStatus =
|
export type QueueItemStatus = 'pending' | 'in_progress' | 'success' | 'unhealthy' | 'error';
|
||||||
| 'pending'
|
|
||||||
| 'in_progress'
|
|
||||||
| 'success'
|
|
||||||
| 'unhealthy'
|
|
||||||
| 'error';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processing phases for queue items
|
* Processing phases for queue items
|
||||||
@@ -28,10 +23,7 @@ export type QueueItemStatus =
|
|||||||
* - parsing: Parsing recipe from extracted text
|
* - parsing: Parsing recipe from extracted text
|
||||||
* - uploading: Uploading recipe to Tandoor
|
* - uploading: Uploading recipe to Tandoor
|
||||||
*/
|
*/
|
||||||
export type ProcessingPhase =
|
export type ProcessingPhase = 'extraction' | 'parsing' | 'uploading';
|
||||||
| 'extraction'
|
|
||||||
| 'parsing'
|
|
||||||
| 'uploading';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase progress information
|
* Phase progress information
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ async function renewInstagramAuth(): Promise<boolean> {
|
|||||||
const authPath = resolveAuthPath();
|
const authPath = resolveAuthPath();
|
||||||
|
|
||||||
if (!fs.existsSync(authPath)) {
|
if (!fs.existsSync(authPath)) {
|
||||||
console.warn('[Scheduler] No existing auth.json found. Run gen-auth.js first to set up initial authentication.');
|
console.warn(
|
||||||
|
'[Scheduler] No existing auth.json found. Run gen-auth.js first to set up initial authentication.'
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +117,9 @@ async function renewInstagramAuth(): Promise<boolean> {
|
|||||||
await context.storageState({ path: authPath });
|
await context.storageState({ path: authPath });
|
||||||
|
|
||||||
state.lastRenewalTime = Date.now();
|
state.lastRenewalTime = Date.now();
|
||||||
console.log(`[Scheduler] Instagram authentication renewed successfully at ${new Date().toISOString()}`);
|
console.log(
|
||||||
|
`[Scheduler] Instagram authentication renewed successfully at ${new Date().toISOString()}`
|
||||||
|
);
|
||||||
console.log(`[Scheduler] Auth state updated at: ${authPath}`);
|
console.log(`[Scheduler] Auth state updated at: ${authPath}`);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -140,7 +144,9 @@ export async function startScheduler(): Promise<void> {
|
|||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
console.log('[Scheduler] Authentication scheduler is disabled (set AUTH_SCHEDULER_ENABLED=true to enable)');
|
console.log(
|
||||||
|
'[Scheduler] Authentication scheduler is disabled (set AUTH_SCHEDULER_ENABLED=true to enable)'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +157,9 @@ export async function startScheduler(): Promise<void> {
|
|||||||
|
|
||||||
const intervalMs = config.intervalMinutes * 60 * 1000;
|
const intervalMs = config.intervalMinutes * 60 * 1000;
|
||||||
|
|
||||||
console.log(`[Scheduler] Starting authentication scheduler with ${config.intervalMinutes}min interval`);
|
console.log(
|
||||||
|
`[Scheduler] Starting authentication scheduler with ${config.intervalMinutes}min interval`
|
||||||
|
);
|
||||||
|
|
||||||
// Schedule periodic renewals
|
// Schedule periodic renewals
|
||||||
state.intervalId = setInterval(async () => {
|
state.intervalId = setInterval(async () => {
|
||||||
|
|||||||
@@ -15,38 +15,48 @@ export const TandoorRecipeSchema = z.object({
|
|||||||
prep_time: z.string().optional(),
|
prep_time: z.string().optional(),
|
||||||
cook_time: z.string().optional(),
|
cook_time: z.string().optional(),
|
||||||
waiting_time: z.string().optional(),
|
waiting_time: z.string().optional(),
|
||||||
steps: z.array(
|
steps: z
|
||||||
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
step: z.number(),
|
step: z.number(),
|
||||||
instruction: z.string(),
|
instruction: z.string(),
|
||||||
ingredients: z.array(
|
ingredients: z
|
||||||
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
food: z.object({
|
food: z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string()
|
name: z.string()
|
||||||
}),
|
}),
|
||||||
unit: z.object({
|
unit: z
|
||||||
|
.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string()
|
name: z.string()
|
||||||
}).nullable(),
|
})
|
||||||
|
.nullable(),
|
||||||
amount: z.number(),
|
amount: z.number(),
|
||||||
note: z.string().optional()
|
note: z.string().optional()
|
||||||
})
|
})
|
||||||
).optional()
|
)
|
||||||
|
.optional()
|
||||||
})
|
})
|
||||||
).optional(),
|
)
|
||||||
ingredients: z.array(
|
.optional(),
|
||||||
|
ingredients: z
|
||||||
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
food: z.object({
|
food: z.object({
|
||||||
name: z.string()
|
name: z.string()
|
||||||
}),
|
}),
|
||||||
unit: z.object({
|
unit: z
|
||||||
|
.object({
|
||||||
name: z.string()
|
name: z.string()
|
||||||
}).nullable(),
|
})
|
||||||
|
.nullable(),
|
||||||
amount: z.number(),
|
amount: z.number(),
|
||||||
note: z.string().optional()
|
note: z.string().optional()
|
||||||
})
|
})
|
||||||
).optional()
|
)
|
||||||
|
.optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TandoorRecipe = z.infer<typeof TandoorRecipeSchema>;
|
export type TandoorRecipe = z.infer<typeof TandoorRecipeSchema>;
|
||||||
@@ -104,11 +114,11 @@ interface TandoorRecipeDTO {
|
|||||||
*/
|
*/
|
||||||
async function fetchFromTandoor<T>(
|
async function fetchFromTandoor<T>(
|
||||||
url: string,
|
url: string,
|
||||||
options: Partial<RequestInit> = { method: 'GET' },
|
options: Partial<RequestInit> = { method: 'GET' }
|
||||||
): Promise<{ ok: boolean; data?: T; error?: string }> {
|
): Promise<{ ok: boolean; data?: T; error?: string }> {
|
||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
Accept: 'application/json',
|
||||||
Authorization: `Bearer ${tandoorConfig.token}`
|
Authorization: `Bearer ${tandoorConfig.token}`
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,8 +163,6 @@ async function fetchFromTandoor<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partitions ingredients across steps by distributing them evenly
|
* Partitions ingredients across steps by distributing them evenly
|
||||||
* When step association is unknown, this spreads ingredients proportionally
|
* When step association is unknown, this spreads ingredients proportionally
|
||||||
@@ -181,7 +189,10 @@ function partitionIngredientsAcrossSteps(
|
|||||||
partitions[index % stepCount].push(ingredient);
|
partitions[index % stepCount].push(ingredient);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.debug(`Partitioned ${ingredients.length} ingredients across ${stepCount} steps:`, partitions);
|
console.debug(
|
||||||
|
`Partitioned ${ingredients.length} ingredients across ${stepCount} steps:`,
|
||||||
|
partitions
|
||||||
|
);
|
||||||
return partitions;
|
return partitions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,10 +235,7 @@ function parseAmount(amountStr: string): number | null {
|
|||||||
*/
|
*/
|
||||||
function buildTandoorRecipeDTO(recipe: ExtractedRecipe): TandoorRecipeDTO {
|
function buildTandoorRecipeDTO(recipe: ExtractedRecipe): TandoorRecipeDTO {
|
||||||
const stepCount = recipe.steps?.length || 1;
|
const stepCount = recipe.steps?.length || 1;
|
||||||
const ingredientPartitions = partitionIngredientsAcrossSteps(
|
const ingredientPartitions = partitionIngredientsAcrossSteps(recipe.ingredients || [], stepCount);
|
||||||
recipe.ingredients || [],
|
|
||||||
stepCount
|
|
||||||
);
|
|
||||||
|
|
||||||
const steps: TandoorRecipeDTO['steps'] = (recipe.steps || []).map((instruction, index) => {
|
const steps: TandoorRecipeDTO['steps'] = (recipe.steps || []).map((instruction, index) => {
|
||||||
// Map ingredients, converting unparseable amounts to 1 q.b.
|
// Map ingredients, converting unparseable amounts to 1 q.b.
|
||||||
@@ -235,7 +243,9 @@ function buildTandoorRecipeDTO(recipe: ExtractedRecipe): TandoorRecipeDTO {
|
|||||||
const amount = parseAmount(ing.amount);
|
const amount = parseAmount(ing.amount);
|
||||||
|
|
||||||
if (amount === null) {
|
if (amount === null) {
|
||||||
console.debug(`Converting ingredient with unparseable amount to 1 q.b.: ${ing.item} (${ing.amount})`);
|
console.debug(
|
||||||
|
`Converting ingredient with unparseable amount to 1 q.b.: ${ing.item} (${ing.amount})`
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
food: {
|
food: {
|
||||||
name: ing.item
|
name: ing.item
|
||||||
@@ -297,14 +307,11 @@ export async function uploadRecipeWithIngredientsDTO(
|
|||||||
console.debug('Uploading recipe with ingredients DTO:', recipeDTO);
|
console.debug('Uploading recipe with ingredients DTO:', recipeDTO);
|
||||||
|
|
||||||
// Call the API with the DTO
|
// Call the API with the DTO
|
||||||
const recipeResult = await fetchFromTandoor<{ id: number }>(
|
const recipeResult = await fetchFromTandoor<{ id: number }>(`/api/recipe/`, {
|
||||||
`/api/recipe/`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(recipeDTO)
|
body: JSON.stringify(recipeDTO)
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (!recipeResult.ok || !recipeResult.data) {
|
if (!recipeResult.ok || !recipeResult.data) {
|
||||||
console.error('Recipe creation failed:', recipeResult.error);
|
console.error('Recipe creation failed:', recipeResult.error);
|
||||||
@@ -397,7 +404,9 @@ export async function uploadRecipeImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Tandoor Upload] Recipe ID: ${recipeId}`);
|
console.log(`[Tandoor Upload] Recipe ID: ${recipeId}`);
|
||||||
console.log(`[Tandoor Upload] Image type: ${isDirectUrl(imageUrl) ? 'URL' : isDataUrl(imageUrl) ? 'Base64' : 'Unknown'}`);
|
console.log(
|
||||||
|
`[Tandoor Upload] Image type: ${isDirectUrl(imageUrl) ? 'URL' : isDataUrl(imageUrl) ? 'Base64' : 'Unknown'}`
|
||||||
|
);
|
||||||
console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}...`);
|
console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}...`);
|
||||||
|
|
||||||
let buffer: Buffer;
|
let buffer: Buffer;
|
||||||
@@ -462,26 +471,27 @@ export async function uploadRecipeImage(
|
|||||||
formData.append('image', file);
|
formData.append('image', file);
|
||||||
|
|
||||||
console.log('[Tandoor Upload] Uploading to Tandoor...');
|
console.log('[Tandoor Upload] Uploading to Tandoor...');
|
||||||
const uploadResponse = await fetch(
|
const uploadResponse = await fetch(`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`, {
|
||||||
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
// DO NOT set Content-Type - let fetch set it with boundary
|
// DO NOT set Content-Type - let fetch set it with boundary
|
||||||
},
|
},
|
||||||
body: formData
|
body: formData
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (!uploadResponse.ok) {
|
if (!uploadResponse.ok) {
|
||||||
const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText);
|
const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText);
|
||||||
const responseHeaders = JSON.stringify(Object.fromEntries(uploadResponse.headers.entries()));
|
const responseHeaders = JSON.stringify(Object.fromEntries(uploadResponse.headers.entries()));
|
||||||
|
|
||||||
console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
|
console.error(
|
||||||
|
`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`
|
||||||
|
);
|
||||||
console.error(`[Tandoor Upload] Response headers: ${responseHeaders}`);
|
console.error(`[Tandoor Upload] Response headers: ${responseHeaders}`);
|
||||||
console.error(`[Tandoor Upload] Response body: ${errorText.substring(0, 500)}`);
|
console.error(`[Tandoor Upload] Response body: ${errorText.substring(0, 500)}`);
|
||||||
console.error(`[Tandoor Upload] File metadata: ${filename}, ${file.size} bytes, ${file.type}`);
|
console.error(
|
||||||
|
`[Tandoor Upload] File metadata: ${filename}, ${file.size} bytes, ${file.type}`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ export const GET = async () => {
|
|||||||
// Get current queue items by status
|
// Get current queue items by status
|
||||||
const allItems = queueManager.getAll();
|
const allItems = queueManager.getAll();
|
||||||
const statusCounts = {
|
const statusCounts = {
|
||||||
pending: allItems.filter(item => item.status === 'pending').length,
|
pending: allItems.filter((item) => item.status === 'pending').length,
|
||||||
in_progress: allItems.filter(item => item.status === 'in_progress').length,
|
in_progress: allItems.filter((item) => item.status === 'in_progress').length,
|
||||||
success: allItems.filter(item => item.status === 'success').length,
|
success: allItems.filter((item) => item.status === 'success').length,
|
||||||
error: allItems.filter(item => item.status === 'error').length,
|
error: allItems.filter((item) => item.status === 'error').length,
|
||||||
unhealthy: allItems.filter(item => item.status === 'unhealthy').length
|
unhealthy: allItems.filter((item) => item.status === 'unhealthy').length
|
||||||
};
|
};
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
@@ -51,11 +51,14 @@ export const GET = async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Health Check] Error retrieving health status:', error);
|
console.error('[Health Check] Error retrieving health status:', error);
|
||||||
|
|
||||||
return json({
|
return json(
|
||||||
|
{
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'unhealthy',
|
status: 'unhealthy',
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
uptime: process.uptime()
|
uptime: process.uptime()
|
||||||
}, { status: 500 });
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -15,16 +15,22 @@ export async function GET() {
|
|||||||
message: 'LLM service is accessible'
|
message: 'LLM service is accessible'
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return json({
|
return json(
|
||||||
|
{
|
||||||
status: 'unhealthy',
|
status: 'unhealthy',
|
||||||
message: 'LLM service is not accessible'
|
message: 'LLM service is not accessible'
|
||||||
}, { status: 503 });
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return json({
|
return json(
|
||||||
|
{
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: errorMessage
|
message: errorMessage
|
||||||
}, { status: 500 });
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,17 +32,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||||
return json(
|
return json({ error: 'Invalid subscription object' }, { status: 400 });
|
||||||
{ error: 'Invalid subscription object' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clientId || typeof clientId !== 'string') {
|
if (!clientId || typeof clientId !== 'string') {
|
||||||
return json(
|
return json({ error: 'Client ID is required' }, { status: 400 });
|
||||||
{ error: 'Client ID is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe client
|
// Subscribe client
|
||||||
@@ -61,13 +55,9 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
message: 'Successfully subscribed to push notifications',
|
message: 'Successfully subscribed to push notifications',
|
||||||
subscriptionCount: pushNotificationService.getSubscriptionCount()
|
subscriptionCount: pushNotificationService.getSubscriptionCount()
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[NotificationAPI] Subscription error:', error);
|
console.error('[NotificationAPI] Subscription error:', error);
|
||||||
return json(
|
return json({ error: 'Failed to subscribe to notifications' }, { status: 500 });
|
||||||
{ error: 'Failed to subscribe to notifications' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,10 +76,7 @@ export const DELETE: RequestHandler = async ({ request }) => {
|
|||||||
const { clientId } = await request.json();
|
const { clientId } = await request.json();
|
||||||
|
|
||||||
if (!clientId || typeof clientId !== 'string') {
|
if (!clientId || typeof clientId !== 'string') {
|
||||||
return json(
|
return json({ error: 'Client ID is required' }, { status: 400 });
|
||||||
{ error: 'Client ID is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unsubscribe client
|
// Unsubscribe client
|
||||||
@@ -102,12 +89,8 @@ export const DELETE: RequestHandler = async ({ request }) => {
|
|||||||
message: 'Successfully unsubscribed from push notifications',
|
message: 'Successfully unsubscribed from push notifications',
|
||||||
subscriptionCount: pushNotificationService.getSubscriptionCount()
|
subscriptionCount: pushNotificationService.getSubscriptionCount()
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[NotificationAPI] Unsubscription error:', error);
|
console.error('[NotificationAPI] Unsubscription error:', error);
|
||||||
return json(
|
return json({ error: 'Failed to unsubscribe from notifications' }, { status: 500 });
|
||||||
{ error: 'Failed to unsubscribe from notifications' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -69,13 +69,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
message: `Test ${type} notification sent`,
|
message: `Test ${type} notification sent`,
|
||||||
subscriberCount: pushNotificationService.getSubscriptionCount()
|
subscriberCount: pushNotificationService.getSubscriptionCount()
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[NotificationTestAPI] Error sending test notification:',
|
console.error(
|
||||||
error instanceof Error ? error.message : String(error));
|
'[NotificationTestAPI] Error sending test notification:',
|
||||||
return json(
|
error instanceof Error ? error.message : String(error)
|
||||||
{ error: 'Failed to send test notification' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
);
|
||||||
|
return json({ error: 'Failed to send test notification' }, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,22 +25,15 @@ export const GET: RequestHandler = async () => {
|
|||||||
const publicKey = pushNotificationService.getPublicVapidKey();
|
const publicKey = pushNotificationService.getPublicVapidKey();
|
||||||
|
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
return json(
|
return json({ error: 'VAPID public key not configured' }, { status: 503 });
|
||||||
{ error: 'VAPID public key not configured' },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
publicKey,
|
publicKey,
|
||||||
applicationServerKey: publicKey // Alias for compatibility
|
applicationServerKey: publicKey // Alias for compatibility
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[NotificationAPI] VAPID key error:', error);
|
console.error('[NotificationAPI] VAPID key error:', error);
|
||||||
return json(
|
return json({ error: 'Failed to get VAPID public key' }, { status: 500 });
|
||||||
{ error: 'Failed to get VAPID public key' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -60,7 +60,6 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
status: queueItem.status,
|
status: queueItem.status,
|
||||||
enqueuedAt: queueItem.enqueuedAt
|
enqueuedAt: queueItem.enqueuedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
@@ -122,7 +121,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
|
|
||||||
// Apply status filter
|
// Apply status filter
|
||||||
if (statusFilter) {
|
if (statusFilter) {
|
||||||
items = items.filter(item => item.status === statusFilter);
|
items = items.filter((item) => item.status === statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by enqueued time (newest first)
|
// Sort by enqueued time (newest first)
|
||||||
@@ -130,7 +129,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
const paginatedItems = items.slice(offset, offset + limit);
|
const paginatedItems = items.slice(offset, offset + limit);
|
||||||
const hasMore = (offset + limit) < items.length;
|
const hasMore = offset + limit < items.length;
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
items: paginatedItems,
|
items: paginatedItems,
|
||||||
@@ -142,7 +141,6 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
count: paginatedItems.length
|
count: paginatedItems.length
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const GET: RequestHandler = async ({ params }) => {
|
|||||||
|
|
||||||
// Return full item details
|
// Return full item details
|
||||||
return json(queueItem);
|
return json(queueItem);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
@@ -78,9 +77,7 @@ export const DELETE: RequestHandler = async ({ params }) => {
|
|||||||
|
|
||||||
// Prevent deletion of in-progress items
|
// Prevent deletion of in-progress items
|
||||||
if (existingItem.status === 'in_progress') {
|
if (existingItem.status === 'in_progress') {
|
||||||
throw new ConflictError(
|
throw new ConflictError('Cannot delete item that is currently being processed');
|
||||||
'Cannot delete item that is currently being processed'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the item
|
// Remove the item
|
||||||
@@ -90,7 +87,6 @@ export const DELETE: RequestHandler = async ({ params }) => {
|
|||||||
success,
|
success,
|
||||||
message: 'Queue item removed successfully'
|
message: 'Queue item removed successfully'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export const POST: RequestHandler = async ({ params }) => {
|
|||||||
item: updatedItem,
|
item: updatedItem,
|
||||||
message: 'Queue item has been reset and will be reprocessed'
|
message: 'Queue item has been reset and will be reprocessed'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,10 +108,10 @@ export const GET: RequestHandler = async ({ url, request }) => {
|
|||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if (itemIdFilter) {
|
if (itemIdFilter) {
|
||||||
filteredItems = currentItems.filter(item => item.id === itemIdFilter);
|
filteredItems = currentItems.filter((item) => item.id === itemIdFilter);
|
||||||
}
|
}
|
||||||
if (statusFilter) {
|
if (statusFilter) {
|
||||||
filteredItems = filteredItems.filter(item => item.status === statusFilter);
|
filteredItems = filteredItems.filter((item) => item.status === statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send initial state for each matching item
|
// Send initial state for each matching item
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import {tandoorConfig} from '$lib/server/tandoor-config';
|
import { tandoorConfig } from '$lib/server/tandoor-config';
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return json({...tandoorConfig, token: ''});
|
return json({ ...tandoorConfig, token: '' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,4 +40,4 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -169,9 +169,7 @@ self.addEventListener('push', (event) => {
|
|||||||
|
|
||||||
const title = data.title || getNotificationTitle(data.type, data);
|
const title = data.title || getNotificationTitle(data.type, data);
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
self.registration.showNotification(title, options)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle notification clicks
|
// Handle notification clicks
|
||||||
@@ -195,8 +193,7 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientsList) => {
|
||||||
.then((clientsList) => {
|
|
||||||
// Check if there's already a window/tab open
|
// Check if there's already a window/tab open
|
||||||
for (const client of clientsList) {
|
for (const client of clientsList) {
|
||||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ describe('extraction.ts logging', () => {
|
|||||||
expect(calls.length).toBeGreaterThan(0);
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Verify at least one call has the expected format
|
// Verify at least one call has the expected format
|
||||||
const errorCall = calls.find((call: any[]) =>
|
const errorCall = calls.find(
|
||||||
call[0]?.match(/\[.*\]/) && call[1] !== undefined
|
(call: any[]) => call[0]?.match(/\[.*\]/) && call[1] !== undefined
|
||||||
);
|
);
|
||||||
expect(errorCall).toBeDefined();
|
expect(errorCall).toBeDefined();
|
||||||
expect(errorCall[0]).toMatch(/\[.*\]/); // Has prefix like [Retry], [Extractor], etc
|
expect(errorCall[0]).toMatch(/\[.*\]/); // Has prefix like [Retry], [Extractor], etc
|
||||||
@@ -49,14 +49,11 @@ describe('extraction.ts logging', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check all console.warn and console.error calls
|
// Check all console.warn and console.error calls
|
||||||
const allCalls = [
|
const allCalls = [...consoleWarnSpy.mock.calls, ...consoleErrorSpy.mock.calls];
|
||||||
...consoleWarnSpy.mock.calls,
|
|
||||||
...consoleErrorSpy.mock.calls
|
|
||||||
];
|
|
||||||
|
|
||||||
const errorCalls = allCalls
|
const errorCalls = allCalls
|
||||||
.map(call => call.join(' '))
|
.map((call) => call.join(' '))
|
||||||
.filter(msg => msg.includes('[object Object]'));
|
.filter((msg) => msg.includes('[object Object]'));
|
||||||
|
|
||||||
expect(errorCalls).toHaveLength(0);
|
expect(errorCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
@@ -78,9 +75,7 @@ describe('extraction.ts logging', () => {
|
|||||||
// Call real logError
|
// Call real logError
|
||||||
logger.logError('[Test] Real test', mockError);
|
logger.logError('[Test] Real test', mockError);
|
||||||
|
|
||||||
const output = consoleErrorSpy.mock.calls
|
const output = consoleErrorSpy.mock.calls.map((call) => call.join(' ')).join(' ');
|
||||||
.map(call => call.join(' '))
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
// Should not contain [object Object]
|
// Should not contain [object Object]
|
||||||
expect(output).not.toContain('[object Object]');
|
expect(output).not.toContain('[object Object]');
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ describe('Instagram Caption Extraction E2E', () => {
|
|||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
|
const testUrl =
|
||||||
|
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
|
||||||
console.log('[DEBUG] Navigating to:', testUrl);
|
console.log('[DEBUG] Navigating to:', testUrl);
|
||||||
|
|
||||||
await page.goto(testUrl, { waitUntil: 'domcontentloaded' });
|
await page.goto(testUrl, { waitUntil: 'domcontentloaded' });
|
||||||
@@ -71,7 +72,6 @@ describe('Instagram Caption Extraction E2E', () => {
|
|||||||
console.log(`\n[DEBUG] Shortcode appears ${htmlMatches} times in page HTML`);
|
console.log(`\n[DEBUG] Shortcode appears ${htmlMatches} times in page HTML`);
|
||||||
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
await page.close();
|
await page.close();
|
||||||
await context.close();
|
await context.close();
|
||||||
@@ -84,7 +84,8 @@ describe('Instagram Caption Extraction E2E', () => {
|
|||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
|
const testUrl =
|
||||||
|
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
|
||||||
console.log('[DEBUG] Navigating to:', testUrl);
|
console.log('[DEBUG] Navigating to:', testUrl);
|
||||||
|
|
||||||
await page.goto(testUrl, { waitUntil: 'domcontentloaded' });
|
await page.goto(testUrl, { waitUntil: 'domcontentloaded' });
|
||||||
@@ -96,7 +97,10 @@ describe('Instagram Caption Extraction E2E', () => {
|
|||||||
|
|
||||||
// Try to find and click "more" button
|
// Try to find and click "more" button
|
||||||
console.log('[DEBUG] Looking for "more" button...');
|
console.log('[DEBUG] Looking for "more" button...');
|
||||||
const moreElements = await page.locator('span, div, button').filter({ hasText: /more/i }).all();
|
const moreElements = await page
|
||||||
|
.locator('span, div, button')
|
||||||
|
.filter({ hasText: /more/i })
|
||||||
|
.all();
|
||||||
console.log(`[DEBUG] Found ${moreElements.length} elements with "more"`);
|
console.log(`[DEBUG] Found ${moreElements.length} elements with "more"`);
|
||||||
|
|
||||||
for (let i = 0; i < Math.min(moreElements.length, 10); i++) {
|
for (let i = 0; i < Math.min(moreElements.length, 10); i++) {
|
||||||
@@ -126,7 +130,7 @@ describe('Instagram Caption Extraction E2E', () => {
|
|||||||
const spanData = await page.evaluate(() => {
|
const spanData = await page.evaluate(() => {
|
||||||
const spans = Array.from(document.querySelectorAll('span'));
|
const spans = Array.from(document.querySelectorAll('span'));
|
||||||
return spans
|
return spans
|
||||||
.filter(s => (s.textContent || '').length > 30)
|
.filter((s) => (s.textContent || '').length > 30)
|
||||||
.map((s, idx) => ({
|
.map((s, idx) => ({
|
||||||
index: idx,
|
index: idx,
|
||||||
text: (s.textContent || '').substring(0, 200),
|
text: (s.textContent || '').substring(0, 200),
|
||||||
@@ -139,13 +143,14 @@ describe('Instagram Caption Extraction E2E', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('[DEBUG] Top spans by LENGTH after click attempt:');
|
console.log('[DEBUG] Top spans by LENGTH after click attempt:');
|
||||||
spanData.slice(0, 5).forEach(span => {
|
spanData.slice(0, 5).forEach((span) => {
|
||||||
console.log(` [${span.index}] BR:${span.brCount} Links:${span.linkCount} Len:${span.length}`);
|
console.log(
|
||||||
|
` [${span.index}] BR:${span.brCount} Links:${span.linkCount} Len:${span.length}`
|
||||||
|
);
|
||||||
console.log(` Text: "${span.text}"`);
|
console.log(` Text: "${span.text}"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(true).toBe(true); // Dummy assertion
|
expect(true).toBe(true); // Dummy assertion
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
await page.close();
|
await page.close();
|
||||||
await context.close();
|
await context.close();
|
||||||
@@ -156,7 +161,8 @@ describe('Instagram Caption Extraction E2E', () => {
|
|||||||
// Instagram's current anti-scraping measures make full extraction difficult
|
// Instagram's current anti-scraping measures make full extraction difficult
|
||||||
// This test validates that we try all available methods
|
// This test validates that we try all available methods
|
||||||
|
|
||||||
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
|
const testUrl =
|
||||||
|
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
|
||||||
|
|
||||||
const result = await extractTextAndThumbnail(testUrl);
|
const result = await extractTextAndThumbnail(testUrl);
|
||||||
|
|
||||||
@@ -191,7 +197,8 @@ describe('Instagram Caption Extraction E2E', () => {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
it('should handle extraction attempt and return truncated text gracefully', async () => {
|
it('should handle extraction attempt and return truncated text gracefully', async () => {
|
||||||
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
|
const testUrl =
|
||||||
|
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
|
||||||
|
|
||||||
const result = await extractTextAndThumbnail(testUrl);
|
const result = await extractTextAndThumbnail(testUrl);
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ describe('extractFromDOM() with mocked og:description', () => {
|
|||||||
|
|
||||||
it('should remove metadata prefix from og:description fallback', async () => {
|
it('should remove metadata prefix from og:description fallback', async () => {
|
||||||
// Exact fixture from context_compact.yaml
|
// Exact fixture from context_compact.yaml
|
||||||
const ogContent = '16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
|
const ogContent =
|
||||||
|
'16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
|
||||||
|
|
||||||
const mockPage = createMockPage(ogContent);
|
const mockPage = createMockPage(ogContent);
|
||||||
|
|
||||||
@@ -104,7 +105,8 @@ describe('extractFromDOM() with mocked og:description', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should remove opening quote after metadata prefix', async () => {
|
it('should remove opening quote after metadata prefix', async () => {
|
||||||
const ogContent = '16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
|
const ogContent =
|
||||||
|
'16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
|
||||||
|
|
||||||
const mockPage = createMockPage(ogContent);
|
const mockPage = createMockPage(ogContent);
|
||||||
|
|
||||||
@@ -168,7 +170,8 @@ describe('Integration: Full extraction flow', () => {
|
|||||||
it('should extract, clean metadata prefix, remove quotes, and clean hashtags', async () => {
|
it('should extract, clean metadata prefix, remove quotes, and clean hashtags', async () => {
|
||||||
// Simulating what the browser's page.evaluate() would return AFTER cleaning metadata
|
// Simulating what the browser's page.evaluate() would return AFTER cleaning metadata
|
||||||
// (the browser regex already strips the metadata prefix and quotes)
|
// (the browser regex already strips the metadata prefix and quotes)
|
||||||
const browserCleanedContent = 'La cacio e pepe infallibile di Luciano Monosilio 🍝 #cacio #pepe #recipe';
|
const browserCleanedContent =
|
||||||
|
'La cacio e pepe infallibile di Luciano Monosilio 🍝 #cacio #pepe #recipe';
|
||||||
|
|
||||||
const mockPage = createMockPage(browserCleanedContent);
|
const mockPage = createMockPage(browserCleanedContent);
|
||||||
|
|
||||||
@@ -197,7 +200,8 @@ describe('Integration: Full extraction flow', () => {
|
|||||||
|
|
||||||
it('should handle full real-world caption with multiline content', async () => {
|
it('should handle full real-world caption with multiline content', async () => {
|
||||||
// Browser has already cleaned metadata, only hashtags remain
|
// Browser has already cleaned metadata, only hashtags remain
|
||||||
const browserCleanedContent = 'La cacio e pepe\n\nIngredients:\n- Pasta\n- Cheese\n\n#recipe #pasta';
|
const browserCleanedContent =
|
||||||
|
'La cacio e pepe\n\nIngredients:\n- Pasta\n- Cheese\n\n#recipe #pasta';
|
||||||
|
|
||||||
const mockPage = createMockPage(browserCleanedContent);
|
const mockPage = createMockPage(browserCleanedContent);
|
||||||
|
|
||||||
|
|||||||
@@ -76,9 +76,6 @@ describe('llm.ts logging', () => {
|
|||||||
|
|
||||||
await checkModelAvailability('test-model');
|
await checkModelAvailability('test-model');
|
||||||
|
|
||||||
expect(logErrorSpy).toHaveBeenCalledWith(
|
expect(logErrorSpy).toHaveBeenCalledWith('[LLM] Model availability check failed', complexError);
|
||||||
'[LLM] Model availability check failed',
|
|
||||||
complexError
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,10 +149,7 @@ describe('logger utilities', () => {
|
|||||||
|
|
||||||
logObject('[Test]', obj);
|
logObject('[Test]', obj);
|
||||||
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
expect(consoleLogSpy).toHaveBeenCalledWith('[Test]', expect.stringContaining('[Circular]'));
|
||||||
'[Test]',
|
|
||||||
expect.stringContaining('[Circular]')
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ describe('POST /api/notifications/test', () => {
|
|||||||
|
|
||||||
// Spy on pushNotificationService methods
|
// Spy on pushNotificationService methods
|
||||||
sendNotificationSpy = vi.spyOn(pushNotificationService, 'sendNotification').mockResolvedValue();
|
sendNotificationSpy = vi.spyOn(pushNotificationService, 'sendNotification').mockResolvedValue();
|
||||||
getSubscriptionCountSpy = vi.spyOn(pushNotificationService, 'getSubscriptionCount').mockReturnValue(2);
|
getSubscriptionCountSpy = vi
|
||||||
|
.spyOn(pushNotificationService, 'getSubscriptionCount')
|
||||||
|
.mockReturnValue(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate notification type - reject invalid type', async () => {
|
test('should validate notification type - reject invalid type', async () => {
|
||||||
@@ -179,7 +181,7 @@ describe('POST /api/notifications/test', () => {
|
|||||||
const call1 = sendNotificationSpy.mock.calls[0][0];
|
const call1 = sendNotificationSpy.mock.calls[0][0];
|
||||||
|
|
||||||
// Wait a bit to ensure different timestamp
|
// Wait a bit to ensure different timestamp
|
||||||
await new Promise(resolve => setTimeout(resolve, 2));
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||||
|
|
||||||
await POST({ request: request2 } as any);
|
await POST({ request: request2 } as any);
|
||||||
const call2 = sendNotificationSpy.mock.calls[1][0];
|
const call2 = sendNotificationSpy.mock.calls[1][0];
|
||||||
|
|||||||
@@ -47,10 +47,7 @@ describe('parser.ts logging', () => {
|
|||||||
// Expected to throw
|
// Expected to throw
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(logErrorSpy).toHaveBeenCalledWith(
|
expect(logErrorSpy).toHaveBeenCalledWith('[LLM] Recipe detection error', expect.any(Error));
|
||||||
'[LLM] Recipe detection error',
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseRecipe should use logError on failure', async () => {
|
test('parseRecipe should use logError on failure', async () => {
|
||||||
@@ -60,10 +57,7 @@ describe('parser.ts logging', () => {
|
|||||||
// Expected to throw
|
// Expected to throw
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(logErrorSpy).toHaveBeenCalledWith(
|
expect(logErrorSpy).toHaveBeenCalledWith('[LLM] Recipe parsing error', expect.any(Error));
|
||||||
'[LLM] Recipe parsing error',
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not log stack trace separately', async () => {
|
test('should not log stack trace separately', async () => {
|
||||||
@@ -73,8 +67,9 @@ describe('parser.ts logging', () => {
|
|||||||
// Expected to throw
|
// Expected to throw
|
||||||
}
|
}
|
||||||
|
|
||||||
const stackCalls = consoleErrorSpy.mock.calls
|
const stackCalls = consoleErrorSpy.mock.calls.filter((call: any) =>
|
||||||
.filter((call: any) => call[0]?.includes('Stack trace'));
|
call[0]?.includes('Stack trace')
|
||||||
|
);
|
||||||
|
|
||||||
expect(stackCalls).toHaveLength(0);
|
expect(stackCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,11 +98,9 @@ describe('PushNotificationService web-push integration', () => {
|
|||||||
body: 'Progress update'
|
body: 'Progress update'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(webpush.sendNotification).toHaveBeenCalledWith(
|
expect(webpush.sendNotification).toHaveBeenCalledWith(expect.any(Object), expect.any(String), {
|
||||||
expect.any(Object),
|
TTL: 60 * 60 * 24
|
||||||
expect.any(String),
|
});
|
||||||
{ TTL: 60 * 60 * 24 }
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should serialize notification data as JSON', async () => {
|
test('should serialize notification data as JSON', async () => {
|
||||||
@@ -165,7 +163,8 @@ describe('PushNotificationService web-push integration', () => {
|
|||||||
test('should log endpoint prefix only (privacy)', async () => {
|
test('should log endpoint prefix only (privacy)', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'log');
|
const consoleSpy = vi.spyOn(console, 'log');
|
||||||
|
|
||||||
const longEndpoint = 'https://push.example.com/very-long-endpoint-with-secret-tokens-that-should-not-be-fully-logged-12345678901234567890';
|
const longEndpoint =
|
||||||
|
'https://push.example.com/very-long-endpoint-with-secret-tokens-that-should-not-be-fully-logged-12345678901234567890';
|
||||||
const mockSubscription = {
|
const mockSubscription = {
|
||||||
endpoint: longEndpoint,
|
endpoint: longEndpoint,
|
||||||
keys: { p256dh: 'test-key', auth: 'test-auth' }
|
keys: { p256dh: 'test-key', auth: 'test-auth' }
|
||||||
@@ -182,7 +181,7 @@ describe('PushNotificationService web-push integration', () => {
|
|||||||
|
|
||||||
// Find the log call with endpoint
|
// Find the log call with endpoint
|
||||||
const endpointLogCall = consoleSpy.mock.calls.find(
|
const endpointLogCall = consoleSpy.mock.calls.find(
|
||||||
call => typeof call[0] === 'string' && call[0].includes('Sent notification to')
|
(call) => typeof call[0] === 'string' && call[0].includes('Sent notification to')
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(endpointLogCall).toBeTruthy();
|
expect(endpointLogCall).toBeTruthy();
|
||||||
|
|||||||
@@ -49,10 +49,12 @@ test.describe('Push Notifications E2E', () => {
|
|||||||
const subscription = await page.evaluate(async () => {
|
const subscription = await page.evaluate(async () => {
|
||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
const sub = await registration.pushManager.getSubscription();
|
const sub = await registration.pushManager.getSubscription();
|
||||||
return sub ? {
|
return sub
|
||||||
|
? {
|
||||||
endpoint: sub.endpoint,
|
endpoint: sub.endpoint,
|
||||||
hasKeys: !!(sub as any).keys
|
hasKeys: !!(sub as any).keys
|
||||||
} : null;
|
}
|
||||||
|
: null;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(subscription).not.toBeNull();
|
expect(subscription).not.toBeNull();
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import { POST as retryPOST } from '../routes/api/queue/[id]/retry/+server.js';
|
|||||||
describe('Queue API Endpoints', () => {
|
describe('Queue API Endpoints', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear queue between tests
|
// Clear queue between tests
|
||||||
queueManager.getAll().forEach(item => queueManager.remove(item.id));
|
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Clean up after tests
|
// Clean up after tests
|
||||||
queueManager.getAll().forEach(item => queueManager.remove(item.id));
|
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/queue', () => {
|
describe('POST /api/queue', () => {
|
||||||
@@ -26,7 +26,7 @@ describe('Queue API Endpoints', () => {
|
|||||||
const request = new Request('http://localhost/api/queue', {
|
const request = new Request('http://localhost/api/queue', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: 'https://instagram.com/p/ABC123'
|
url: 'https://instagram.com/p/ABC123'
|
||||||
@@ -52,7 +52,7 @@ describe('Queue API Endpoints', () => {
|
|||||||
const request = new Request('http://localhost/api/queue', {
|
const request = new Request('http://localhost/api/queue', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: 'https://www.instagram.com/p/XYZ789'
|
url: 'https://www.instagram.com/p/XYZ789'
|
||||||
@@ -98,7 +98,9 @@ describe('Queue API Endpoints', () => {
|
|||||||
const response = await queuePOST({ request } as any);
|
const response = await queuePOST({ request } as any);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.url).toBe('https://www.instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link');
|
expect(data.url).toBe(
|
||||||
|
'https://www.instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept Instagram IGTV URLs', async () => {
|
it('should accept Instagram IGTV URLs', async () => {
|
||||||
@@ -135,17 +137,13 @@ describe('Queue API Endpoints', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject invalid Instagram URL formats', async () => {
|
it('should reject invalid Instagram URL formats', async () => {
|
||||||
const invalidUrls = [
|
const invalidUrls = ['https://facebook.com/post/123', 'not-a-url', 'https://other-site.com'];
|
||||||
'https://facebook.com/post/123',
|
|
||||||
'not-a-url',
|
|
||||||
'https://other-site.com'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const url of invalidUrls) {
|
for (const url of invalidUrls) {
|
||||||
const request = new Request('http://localhost/api/queue', {
|
const request = new Request('http://localhost/api/queue', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ url })
|
body: JSON.stringify({ url })
|
||||||
});
|
});
|
||||||
@@ -199,7 +197,7 @@ describe('Queue API Endpoints', () => {
|
|||||||
const request = new Request('http://localhost/api/queue', {
|
const request = new Request('http://localhost/api/queue', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
});
|
});
|
||||||
@@ -219,7 +217,7 @@ describe('Queue API Endpoints', () => {
|
|||||||
const request = new Request('http://localhost/api/queue', {
|
const request = new Request('http://localhost/api/queue', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/plain',
|
'Content-Type': 'text/plain'
|
||||||
},
|
},
|
||||||
body: 'not json'
|
body: 'not json'
|
||||||
});
|
});
|
||||||
@@ -321,10 +319,14 @@ describe('Queue API Endpoints', () => {
|
|||||||
let response = await queueGET({ request, url } as any);
|
let response = await queueGET({ request, url } as any);
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
let data = await response.json();
|
let data = await response.json();
|
||||||
expect(data.message).toBe('Invalid status filter. Must be one of: pending, in_progress, success, unhealthy, error');
|
expect(data.message).toBe(
|
||||||
|
'Invalid status filter. Must be one of: pending, in_progress, success, unhealthy, error'
|
||||||
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
expect(err.status).toBe(400);
|
expect(err.status).toBe(400);
|
||||||
expect(err.body.message).toBe('Invalid status filter. Must be one of: pending, in_progress, success, unhealthy, error');
|
expect(err.body.message).toBe(
|
||||||
|
'Invalid status filter. Must be one of: pending, in_progress, success, unhealthy, error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalid limit (negative)
|
// Invalid limit (negative)
|
||||||
@@ -485,10 +487,14 @@ describe('Queue API Endpoints', () => {
|
|||||||
} as any);
|
} as any);
|
||||||
expect(response.status).toBe(409);
|
expect(response.status).toBe(409);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.message).toBe("Cannot retry item with status 'pending'. Only 'error' and 'unhealthy' items can be retried.");
|
expect(data.message).toBe(
|
||||||
|
"Cannot retry item with status 'pending'. Only 'error' and 'unhealthy' items can be retried."
|
||||||
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
expect(err.status).toBe(409);
|
expect(err.status).toBe(409);
|
||||||
expect(err.body.message).toBe("Cannot retry item with status 'pending'. Only 'error' and 'unhealthy' items can be retried.");
|
expect(err.body.message).toBe(
|
||||||
|
"Cannot retry item with status 'pending'. Only 'error' and 'unhealthy' items can be retried."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ describe('QueueManager logging', () => {
|
|||||||
// Enqueue an item (this will notify subscribers)
|
// Enqueue an item (this will notify subscribers)
|
||||||
manager.enqueue('https://instagram.com/p/test123');
|
manager.enqueue('https://instagram.com/p/test123');
|
||||||
|
|
||||||
expect(logErrorSpy).toHaveBeenCalledWith(
|
expect(logErrorSpy).toHaveBeenCalledWith('[QueueManager] Subscriber error', expect.any(Error));
|
||||||
'[QueueManager] Subscriber error',
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should serialize complex error objects', () => {
|
test('should serialize complex error objects', () => {
|
||||||
@@ -49,10 +46,7 @@ describe('QueueManager logging', () => {
|
|||||||
manager.subscribe(failingCallback);
|
manager.subscribe(failingCallback);
|
||||||
manager.enqueue('https://instagram.com/p/test456');
|
manager.enqueue('https://instagram.com/p/test456');
|
||||||
|
|
||||||
expect(logErrorSpy).toHaveBeenCalledWith(
|
expect(logErrorSpy).toHaveBeenCalledWith('[QueueManager] Subscriber error', complexError);
|
||||||
'[QueueManager] Subscriber error',
|
|
||||||
complexError
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not prevent other subscribers from being notified on error', () => {
|
test('should not prevent other subscribers from being notified on error', () => {
|
||||||
@@ -74,12 +68,9 @@ describe('QueueManager logging', () => {
|
|||||||
expect(successCallback).toHaveBeenCalled();
|
expect(successCallback).toHaveBeenCalled();
|
||||||
|
|
||||||
// Should not contain [object Object] in console output
|
// Should not contain [object Object] in console output
|
||||||
const errorMessages = consoleErrorSpy.mock.calls
|
const errorMessages = consoleErrorSpy.mock.calls.map((call) => call.join(' '));
|
||||||
.map(call => call.join(' '));
|
|
||||||
|
|
||||||
const hasObjectObject = errorMessages.some(msg =>
|
const hasObjectObject = errorMessages.some((msg) => msg.includes('[object Object]'));
|
||||||
msg.includes('[object Object]')
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(hasObjectObject).toBe(false);
|
expect(hasObjectObject).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import * as extraction from '$lib/server/extraction';
|
|||||||
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
|
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
|
||||||
|
|
||||||
describe('QueueProcessor logging', () => {
|
describe('QueueProcessor logging', () => {
|
||||||
|
|
||||||
let consoleErrorSpy: any;
|
let consoleErrorSpy: any;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -31,13 +30,13 @@ describe('QueueProcessor logging', () => {
|
|||||||
|
|
||||||
// Clear queue
|
// Clear queue
|
||||||
const items = queueManager.getAll();
|
const items = queueManager.getAll();
|
||||||
items.forEach(item => queueManager.remove(item.id));
|
items.forEach((item) => queueManager.remove(item.id));
|
||||||
|
|
||||||
// Setup console.error spy
|
// Setup console.error spy
|
||||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
// Give time for cleanup
|
// Give time for cleanup
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -59,34 +58,37 @@ describe('QueueProcessor logging', () => {
|
|||||||
queueProcessor.start();
|
queueProcessor.start();
|
||||||
|
|
||||||
// Wait for error status
|
// Wait for error status
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
const updated = queueManager.get(item.id);
|
const updated = queueManager.get(item.id);
|
||||||
return updated?.status === 'error' || updated?.status === 'unhealthy';
|
return updated?.status === 'error' || updated?.status === 'unhealthy';
|
||||||
}, { timeout: 5000 });
|
},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
// Stop processor
|
// Stop processor
|
||||||
queueProcessor.stop();
|
queueProcessor.stop();
|
||||||
|
|
||||||
// Wait a bit for all logs to finish
|
// Wait a bit for all logs to finish
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Check that console.error doesn't contain [object Object]
|
// Check that console.error doesn't contain [object Object]
|
||||||
const allCalls = consoleErrorSpy.mock.calls.map((call: any[]) =>
|
const allCalls = consoleErrorSpy.mock.calls.map((call: any[]) =>
|
||||||
call.map(arg => {
|
call
|
||||||
|
.map((arg) => {
|
||||||
if (arg && typeof arg === 'object' && arg.message) {
|
if (arg && typeof arg === 'object' && arg.message) {
|
||||||
return arg.message; // Handle Error objects
|
return arg.message; // Handle Error objects
|
||||||
}
|
}
|
||||||
return String(arg);
|
return String(arg);
|
||||||
}).join(' ')
|
})
|
||||||
|
.join(' ')
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasObjectObject = allCalls.some((msg: string) => msg.includes('[object Object]'));
|
const hasObjectObject = allCalls.some((msg: string) => msg.includes('[object Object]'));
|
||||||
expect(hasObjectObject).toBe(false);
|
expect(hasObjectObject).toBe(false);
|
||||||
|
|
||||||
// Verify QueueProcessor logs are present
|
// Verify QueueProcessor logs are present
|
||||||
const queueProcessorLogs = allCalls.filter((msg: string) =>
|
const queueProcessorLogs = allCalls.filter((msg: string) => msg.includes('[QueueProcessor]'));
|
||||||
msg.includes('[QueueProcessor]')
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(queueProcessorLogs.length).toBeGreaterThan(0);
|
expect(queueProcessorLogs.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ vi.mock('$lib/server/queue/config', () => ({
|
|||||||
serverUrl: 'http://localhost:8080'
|
serverUrl: 'http://localhost:8080'
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
vapidPublicKey: 'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
|
vapidPublicKey:
|
||||||
|
'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
|
||||||
vapidPrivateKey: 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
|
vapidPrivateKey: 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
|
||||||
vapidEmail: 'mailto:test@example.com'
|
vapidEmail: 'mailto:test@example.com'
|
||||||
}
|
}
|
||||||
@@ -72,7 +73,7 @@ import '$lib/server/queue/QueueProcessor';
|
|||||||
describe('QueueProcessor Integration Tests', () => {
|
describe('QueueProcessor Integration Tests', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Clear queue
|
// Clear queue
|
||||||
queueManager.getAll().forEach(item => queueManager.remove(item.id));
|
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
|
||||||
|
|
||||||
// Reset mocks and their implementations
|
// Reset mocks and their implementations
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
@@ -191,9 +192,7 @@ describe('QueueProcessor Integration Tests', () => {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
it('should handle extraction errors', async () => {
|
it('should handle extraction errors', async () => {
|
||||||
vi.mocked(extractTextAndThumbnail).mockRejectedValue(
|
vi.mocked(extractTextAndThumbnail).mockRejectedValue(new Error('Network timeout'));
|
||||||
new Error('Network timeout')
|
|
||||||
);
|
|
||||||
|
|
||||||
const item = queueManager.enqueue('https://instagram.com/p/error');
|
const item = queueManager.enqueue('https://instagram.com/p/error');
|
||||||
|
|
||||||
@@ -249,7 +248,7 @@ describe('QueueProcessor Integration Tests', () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
const items = queueManager.getAll();
|
const items = queueManager.getAll();
|
||||||
const inProgress = items.filter(i => i.status === 'in_progress');
|
const inProgress = items.filter((i) => i.status === 'in_progress');
|
||||||
|
|
||||||
// With concurrency=2, should have max 2 in progress at once
|
// With concurrency=2, should have max 2 in progress at once
|
||||||
expect(inProgress.length).toBeLessThanOrEqual(2);
|
expect(inProgress.length).toBeLessThanOrEqual(2);
|
||||||
@@ -258,7 +257,7 @@ describe('QueueProcessor Integration Tests', () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
const final = queueManager.getAll();
|
const final = queueManager.getAll();
|
||||||
const completed = final.filter(i => i.status === 'success');
|
const completed = final.filter((i) => i.status === 'success');
|
||||||
|
|
||||||
// All 3 should eventually complete
|
// All 3 should eventually complete
|
||||||
expect(completed.length).toBe(3);
|
expect(completed.length).toBe(3);
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import { GET as streamGET } from '../routes/api/queue/stream/+server.js';
|
|||||||
describe('Queue SSE Stream Endpoint', () => {
|
describe('Queue SSE Stream Endpoint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear queue between tests
|
// Clear queue between tests
|
||||||
queueManager.getAll().forEach(item => queueManager.remove(item.id));
|
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Clean up after tests
|
// Clean up after tests
|
||||||
queueManager.getAll().forEach(item => queueManager.remove(item.id));
|
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/queue/stream', () => {
|
describe('GET /api/queue/stream', () => {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ describe('Frontend SSE Parser', () => {
|
|||||||
'embedded-json': '📦',
|
'embedded-json': '📦',
|
||||||
'dom-selector': '🎯',
|
'dom-selector': '🎯',
|
||||||
'graphql-api': '🔌',
|
'graphql-api': '🔌',
|
||||||
'legacy': '📄'
|
legacy: '📄'
|
||||||
};
|
};
|
||||||
return method ? icons[method] || '⚙️' : '⚙️';
|
return method ? icons[method] || '⚙️' : '⚙️';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,18 +24,13 @@ describe('tandoor logging', () => {
|
|||||||
name: 'Test Recipe',
|
name: 'Test Recipe',
|
||||||
servings: 4,
|
servings: 4,
|
||||||
description: 'Test description',
|
description: 'Test description',
|
||||||
ingredients: [
|
ingredients: [{ item: 'Flour', amount: '2', unit: 'cups' }],
|
||||||
{ item: 'Flour', amount: '2', unit: 'cups' }
|
|
||||||
],
|
|
||||||
steps: ['Mix ingredients']
|
steps: ['Mix ingredients']
|
||||||
};
|
};
|
||||||
|
|
||||||
await uploadRecipeWithIngredientsDTO(recipe);
|
await uploadRecipeWithIngredientsDTO(recipe);
|
||||||
|
|
||||||
expect(logErrorSpy).toHaveBeenCalledWith(
|
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor] Fetch error', expect.any(Error));
|
||||||
'[Tandoor] Fetch error',
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should use logError on API error response', async () => {
|
test('should use logError on API error response', async () => {
|
||||||
@@ -80,10 +75,7 @@ describe('tandoor logging', () => {
|
|||||||
|
|
||||||
await uploadRecipeWithIngredientsDTO(recipe);
|
await uploadRecipeWithIngredientsDTO(recipe);
|
||||||
|
|
||||||
expect(logErrorSpy).toHaveBeenCalledWith(
|
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor] Fetch error', expect.any(Error));
|
||||||
'[Tandoor] Fetch error',
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should use logError on image upload failure', async () => {
|
test('should use logError on image upload failure', async () => {
|
||||||
@@ -93,10 +85,7 @@ describe('tandoor logging', () => {
|
|||||||
const result = await uploadRecipeImage(123, 'https://example.com/image.jpg');
|
const result = await uploadRecipeImage(123, 'https://example.com/image.jpg');
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(logErrorSpy).toHaveBeenCalledWith(
|
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor Upload] Exception', error);
|
||||||
'[Tandoor Upload] Exception',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should use logError instead of manual error logging', async () => {
|
test('should use logError instead of manual error logging', async () => {
|
||||||
@@ -112,10 +101,7 @@ describe('tandoor logging', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Verify logError was called (which handles stack trace serialization)
|
// Verify logError was called (which handles stack trace serialization)
|
||||||
expect(logErrorSpy).toHaveBeenCalledWith(
|
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor] Fetch error', error);
|
||||||
'[Tandoor] Fetch error',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
|
|
||||||
// logError itself logs stack traces, which is expected behavior
|
// logError itself logs stack traces, which is expected behavior
|
||||||
// The key is that tandoor.ts uses logError instead of manual logging
|
// The key is that tandoor.ts uses logError instead of manual logging
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ export default defineConfig({
|
|||||||
watch: {
|
watch: {
|
||||||
ignored: ['**/debug_page.txt', '**/.ssl/**', '**/docs/**', '**/secrets/**']
|
ignored: ['**/debug_page.txt', '**/.ssl/**', '**/docs/**', '**/secrets/**']
|
||||||
},
|
},
|
||||||
https: fs.existsSync('./.ssl/localhost.key') && fs.existsSync('./.ssl/localhost.crt')
|
https:
|
||||||
|
fs.existsSync('./.ssl/localhost.key') && fs.existsSync('./.ssl/localhost.crt')
|
||||||
? {
|
? {
|
||||||
key: fs.readFileSync('./.ssl/localhost.key'),
|
key: fs.readFileSync('./.ssl/localhost.key'),
|
||||||
cert: fs.readFileSync('./.ssl/localhost.crt')
|
cert: fs.readFileSync('./.ssl/localhost.crt')
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
tailwindcss(), sveltekit()],
|
|
||||||
test: {
|
test: {
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
projects: [
|
projects: [
|
||||||
|
|||||||
Reference in New Issue
Block a user