- Extract 8 reusable components from monolithic share page - Add LLM health indicator with 30s polling - Implement stealth thumbnail extraction with 4-method cascade - Integrate real-time thumbnail preview component - Reduce share page from 306 to ~140 lines - Add comprehensive outcome documentation Components: - UrlInputSection: URL input and extraction trigger - ProgressIndicator: Loading state display - ExtractedTextViewer: Collapsible text preview - RecipeCard: Recipe display with Tandoor integration - ErrorState: Error handling UI - LogViewer: System logs with color coding - LlmHealthIndicator: LLM status with polling - ThumbnailPreview: Real-time thumbnail display Thumbnail Methods: 1. Meta tag extraction (og:image, twitter:image) 2. Video poster attribute 3. Instagram embedded JSON data 4. Screenshot fallback Stories Completed: - Story 1: Component extraction and refactoring - Story 2: LLM health status indicator - Story 3: Enhanced stealth thumbnail extraction - Story 4: Thumbnail preview integration Closes: RefactorSharePageAndEnhanceThumbnails
27 KiB
Execution Plan: Refactor Share Page and Enhance Thumbnails
Outcome Name: RefactorSharePageAndEnhanceThumbnails
Created: 2025-12-21
Status: Ready for Implementation
Overview
This plan addresses three key improvements to the InstaChef PWA:
- Component Modularization: Split the monolithic 306-line share page into focused, reusable components
- LLM Health Monitoring: Add visual health status indicator for the LLM service
- Stealthy Thumbnail Extraction: Enhance thumbnail extraction with Instagram-friendly stealth techniques
Problem Statement
Current Issues
- Share Page Complexity: The
+page.sveltefile contains 306 lines with mixed concerns (state management, UI rendering, business logic), making it difficult to maintain and test - No LLM Visibility: Users have no way to know if the LLM service is healthy before attempting extraction
- Basic Thumbnail Extraction: Current screenshot-based approach is detectable and may trigger Instagram's anti-bot measures
User Impact
- Difficult to maintain and extend the share page functionality
- Poor user experience when LLM service is down (only discover during extraction)
- Risk of Instagram blocking due to detectable automation patterns
Technical Context
Current Architecture
Frontend:
- Svelte 5.43.8 with modern runes (
$state,$derived,$effect) - TailwindCSS 4.1.17 for styling
- Share page uses snippets for UI modularity
Backend:
- Playwright 1.56.1 for browser automation
- Existing
/api/llm-healthendpoint for service monitoring extractThumbnail()function uses screenshot-based approach
Hexagonal Architecture Alignment
- Domain: extraction.ts contains business logic for thumbnail extraction
- Adapters:
- Primary (Driving): Svelte components, API routes
- Secondary (Driven): Playwright Page interface
- Ports: Clear interfaces between components and domain logic
Stories
Story 1: Refactor Share Page into Modular Components
Priority: High
Complexity: Medium
Estimated Effort: 4 hours
Description
Extract the current snippets from +page.svelte into standalone, reusable Svelte components. This improves maintainability, testability, and follows single responsibility principle.
Acceptance Criteria
- Create
src/routes/share/components/directory - Extract 6 components from current snippets:
UrlInputSection.svelte- URL input and extraction triggerProgressIndicator.svelte- Loading state displayExtractedTextViewer.svelte- Collapsible text previewRecipeCard.svelte- Recipe display with Tandoor integrationErrorState.svelte- Error handling UILogViewer.svelte- System logs display
- Parent
+page.svelteorchestrates state and passes props to components - Reduced
+page.sveltefrom 306 to ~100 lines - All components use Svelte 5 runes (
$state,$props) - Maintain existing functionality with no regressions
- TailwindCSS styling preserved
Technical Specifications
Component Interfaces:
// UrlInputSection.svelte
interface Props {
targetUrl: string | null;
sharedText: string;
sharedUrl: string;
status: string;
onProcess: () => void;
}
// ProgressIndicator.svelte
interface Props {
status: string;
}
// ExtractedTextViewer.svelte
interface Props {
bodyText: string;
}
// RecipeCard.svelte
interface Props {
recipe: Recipe | null;
tandoorEnabled: boolean;
tandoorImporting: boolean;
tandoorError: string | null;
onRetry: () => void;
onImportToTandoor: () => void;
}
// ErrorState.svelte
interface Props {
status: string;
bodyText: string;
onRetry: () => void;
}
// LogViewer.svelte
interface Props {
logs: string[];
currentMethod: string;
status: string;
}
Implementation Steps
- Create
src/routes/share/components/directory - For each component:
- Create new
.sveltefile - Extract relevant snippet code
- Define props interface using
let { prop1, prop2 } = $props() - Convert callbacks to prop functions
- Preserve TailwindCSS classes
- Create new
- Update
+page.svelte:- Import all components
- Remove snippet definitions
- Replace
{@render snippet()}with<Component /> - Pass state and callbacks as props
Testing Strategy
- Visual regression testing (manual verification)
- Test each component in isolation
- Verify state flow from parent to children
- Verify callbacks work correctly
- Test with real Instagram URL extraction
Files Modified
src/routes/share/+page.svelte
Files Created
src/routes/share/components/UrlInputSection.sveltesrc/routes/share/components/ProgressIndicator.sveltesrc/routes/share/components/ExtractedTextViewer.sveltesrc/routes/share/components/RecipeCard.sveltesrc/routes/share/components/ErrorState.sveltesrc/routes/share/components/LogViewer.sveltesrc/routes/share/components/ThumbnailPreview.svelte(Story 4)src/routes/share/components/LlmHealthIndicator.svelte(Story 2)
Story 2: Add LLM Health Status Component
Priority: Medium
Complexity: Low
Estimated Effort: 2 hours
Description
Create a component that monitors the LLM service health using the existing /api/llm-health endpoint and displays a visual indicator to users.
Acceptance Criteria
- Create
LlmHealthIndicator.sveltecomponent - Component polls
/api/llm-healthevery 30 seconds - Visual indicator shows service status:
- 🟢 Green: healthy
- 🟡 Yellow: checking/loading
- 🔴 Red: unhealthy/error
- Tooltip/hover shows detailed status message
- Polling starts on mount and cleans up on unmount
- Component is non-blocking (doesn't prevent extraction)
- Integrated into share page header area
Technical Specifications
API Contract:
// GET /api/llm-health response
{
status: 'healthy' | 'unhealthy' | 'error';
message: string;
}
Component Interface:
// LlmHealthIndicator.svelte
interface Props {
pollInterval?: number; // default: 30000ms
}
interface HealthState {
status: 'checking' | 'healthy' | 'unhealthy' | 'error';
message: string;
lastChecked: Date | null;
}
Implementation Pattern:
<script lang="ts">
let { pollInterval = 30000 } = $props();
let health = $state<HealthState>({
status: 'checking',
message: '',
lastChecked: null
});
async function checkHealth() {
try {
const res = await fetch('/api/llm-health');
const data = await res.json();
health = {
status: data.status === 'healthy' ? 'healthy' : 'unhealthy',
message: data.message,
lastChecked: new Date()
};
} catch (e) {
health = {
status: 'error',
message: e instanceof Error ? e.message : 'Network error',
lastChecked: new Date()
};
}
}
$effect(() => {
checkHealth(); // Initial check
const interval = setInterval(checkHealth, pollInterval);
return () => clearInterval(interval);
});
</script>
<div class="flex items-center gap-2 text-sm">
<div class="flex items-center gap-1">
{#if health.status === 'checking'}
🟡 <span>Checking LLM...</span>
{:else if health.status === 'healthy'}
🟢 <span class="text-green-600">LLM Ready</span>
{:else if health.status === 'unhealthy'}
🔴 <span class="text-red-600">LLM Unavailable</span>
{:else}
🔴 <span class="text-red-600">LLM Error</span>
{/if}
</div>
<div class="text-xs text-gray-500" title={health.message}>
{health.lastChecked ? `Last: ${health.lastChecked.toLocaleTimeString()}` : ''}
</div>
</div>
Implementation Steps
- Create
src/routes/share/components/LlmHealthIndicator.svelte - Implement health checking logic with polling
- Add visual status indicator with appropriate colors
- Implement cleanup in
$effectreturn - Add component to share page header
- Test polling behavior and visual states
Testing Strategy
- Test all health states (checking, healthy, unhealthy, error)
- Verify polling interval works correctly
- Verify cleanup on component unmount
- Test network error handling
- Manual testing with LM Studio running/stopped
Files Created
src/routes/share/components/LlmHealthIndicator.svelte
Files Modified
src/routes/share/+page.svelte(add health indicator to header)
Story 3: Enhance Thumbnail Extraction with Stealth Techniques
Priority: High
Complexity: High
Estimated Effort: 6 hours
Description
Replace the basic screenshot-based thumbnail extraction with a multi-layered stealth approach that tries less detectable methods first, falling back to screenshots only when necessary.
Acceptance Criteria
- Implement
extractThumbnailStealth()function inextraction.ts - Try 4 extraction methods in order:
- Meta tags (og:image, twitter:image)
- Video poster attribute
- Instagram window data structures
- Screenshot fallback (improved)
- Each method logged for debugging
- Return base64 data URI for consistency
- No new dependencies added
- Backward compatible with existing code
- Handle all edge cases (missing elements, CORS, etc.)
- Add 'thumbnail' to ProgressEventType union
- Emit progress event when thumbnail is extracted
- Frontend receives thumbnail data in real-time via SSE
Technical Specifications
Research Findings:
From web research, Instagram thumbnails can be extracted using:
-
Meta Tags (Most Stealthy):
og:image- OpenGraph thumbnailtwitter:image- Twitter card thumbnail- No detection risk, reads HTML only
-
Video Poster Attribute:
<video poster="...">attribute- Direct thumbnail URL
- Low detection risk
-
Instagram Data Structures:
window.__additionalDataLoadedobject- GraphQL data in page
- Medium detection risk
-
Screenshot Fallback:
- Existing method as last resort
- High detection risk but guaranteed to work
Progress Event Integration:
Update the ProgressEventType to include thumbnail events:
export type ProgressEventType = 'status' | 'method' | 'retry' | 'error' | 'thumbnail' | 'complete';
Emit progress event when thumbnail is extracted:
// After successful thumbnail extraction
if (progressCallback) {
progressCallback({
type: 'thumbnail',
message: 'Thumbnail extracted successfully',
data: { thumbnail: thumbnailDataUri },
timestamp: new Date().toISOString()
});
}
Implementation:
/**
* Extract thumbnail from Instagram post using stealth techniques
* Tries multiple methods in order of stealth:
* 1. Meta tags (og:image)
* 2. Video poster attribute
* 3. Instagram window data
* 4. Screenshot fallback
*/
async function extractThumbnailStealth(page: Page, progressCallback?: ProgressCallback): Promise<string | null> {
console.log('[Thumbnail] Starting stealth extraction');
// Method 1: Try meta tags (most stealthy)
try {
const ogImage = await page.getAttribute('meta[property="og:image"]', 'content');
if (ogImage) {
console.log('[Thumbnail] Found og:image meta tag');
const imageBuffer = await fetchImageAsBase64(ogImage);
if (imageBuffer) {
return imageBuffer;
}
}
const twitterImage = await page.getAttribute('meta[name="twitter:image"]', 'content');
if (twitterImage) {
console.log('[Thumbnail] Found twitter:image meta tag');
const imageBuffer = await fetchImageAsBase64(twitterImage);
if (imageBuffer) {
return imageBuffer;
}
}
} catch (e) {
console.log('[Thumbnail] Meta tag method failed:', e);
}
// Method 2: Try video poster attribute
try {
const poster = await page.getAttribute('video', 'poster');
if (poster) {
console.log('[Thumbnail] Found video poster attribute');
const imageBuffer = await fetchImageAsBase64(poster);
if (imageBuffer) {
return imageBuffer;
}
}
} catch (e) {
console.log('[Thumbnail] Video poster method failed:', e);
}
// Method 3: Try Instagram window data structures
try {
const thumbnailUrl = await page.evaluate(() => {
// Check for Instagram's internal data structures
const data = (window as any).__additionalDataLoaded;
if (data) {
// Navigate through Instagram's data structure
for (const key in data) {
const item = data[key];
if (item?.graphql?.shortcode_media?.display_url) {
return item.graphql.shortcode_media.display_url;
}
if (item?.graphql?.shortcode_media?.thumbnail_src) {
return item.graphql.shortcode_media.thumbnail_src;
}
}
}
return null;
});
if (thumbnailUrl) {
console.log('[Thumbnail] Found thumbnail in Instagram data structures');
const imageBuffer = await fetchImageAsBase64(thumbnailUrl);
if (imageBuffer) {
return imageBuffer;
}
}
} catch (e) {
console.log('[Thumbnail] Instagram data method failed:', e);
}
// Method 4: Screenshot fallback (existing method)
console.log('[Thumbnail] Falling back to screenshot method');
return extractThumbnailScreenshot(page);
}
/**
* Helper: Fetch image from URL and convert to base64 data URI
*/
async function fetchImageAsBase64(imageUrl: string): Promise<string | null> {
try {
const response = await fetch(imageUrl);
if (!response.ok) return null;
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const contentType = response.headers.get('content-type') || 'image/jpeg';
return `data:${contentType};base64,${buffer.toString('base64')}`;
} catch (e) {
console.error('[Thumbnail] Failed to fetch image:', e);
return null;
}
}
/**
* Screenshot-based thumbnail extraction (existing method, renamed)
*/
async function extractThumbnailScreenshot(page: Page): Promise<string | null> {
const videoBounds = await page.evaluate(() => {
const video = document.querySelector('video');
if (!video) return null;
const rect = video.getBoundingClientRect();
return {
x: Math.max(0, rect.left),
y: Math.max(0, rect.top),
width: Math.min(rect.width, window.innerWidth),
height: Math.min(rect.height, window.innerHeight)
};
});
let screenshotBuffer: Buffer;
if (videoBounds && videoBounds.width > 0 && videoBounds.height > 0) {
screenshotBuffer = await page.screenshot({
type: 'jpeg',
quality: 85,
clip: videoBounds
});
} else {
console.warn('[Thumbnail] Video element not found, taking full page screenshot');
screenshotBuffer = await page.screenshot({ type: 'jpeg', quality: 85 });
}
return `data:image/jpeg;base64,${screenshotBuffer.toString('base64')}`;
}
Implementation Steps
- Add 'thumbnail' to ProgressEventType union in extraction.ts
- Rename existing
extractThumbnail()toextractThumbnailScreenshot() - Create new
extractThumbnailStealth()function with optional progressCallback parameter - Implement meta tag extraction (Method 1)
- Implement video poster extraction (Method 2)
- Implement Instagram data structure extraction (Method 3)
- Implement
fetchImageAsBase64()helper - Emit progress event after successful thumbnail extraction
- Add comprehensive logging for debugging
- Update all extraction method calls to pass progressCallback
- Test with various Instagram posts (reels, videos, carousels)
Edge Cases & Error Handling
- CORS Issues: Image URLs might have CORS restrictions - handle gracefully
- Missing Elements: Not all posts have all meta tags - try multiple
- Invalid URLs: Validate URLs before fetching
- Network Errors: Timeout and retry logic
- Instagram Format Changes: Fallback ensures functionality
- Private/Deleted Posts: Handle gracefully with null return
Testing Strategy
- Test with multiple Instagram post types:
- Reels
- Video posts
- Carousel posts
- Story highlights
- Test each extraction method independently
- Verify fallback chain works correctly
- Test with network failures
- Monitor Instagram's detection (no blocks)
Files Modified
src/lib/server/extraction.ts
Migration Notes
Replace all occurrences of:
const thumbnail = await extractThumbnail(page);
With:
const thumbnail = await extractThumbnailStealth(page);
The function signature remains the same, ensuring backward compatibility.
Story 4: Create Thumbnail Preview Component
Priority: Medium
Complexity: Low
Estimated Effort: 2 hours
Description
Create a dedicated component to display the extracted thumbnail in real-time as soon as it's available, separate from the recipe display. This provides immediate visual feedback to users during the extraction process.
Acceptance Criteria
- Create
ThumbnailPreview.sveltecomponent - Component displays thumbnail image when available
- Shows loading skeleton while thumbnail is being extracted
- Shows error state if thumbnail extraction fails
- Responsive design with proper aspect ratio
- Integrates into share page between progress indicator and logs
- Updates in real-time when thumbnail event is received
- Maintains aspect ratio and prevents layout shift
Technical Specifications
Component Interface:
// ThumbnailPreview.svelte
interface Props {
thumbnail: string | null;
status: 'idle' | 'extracting' | 'success' | 'error';
}
Implementation Pattern:
<script lang="ts">
let { thumbnail = null, status = 'idle' } = $props();
</script>
{#if status === 'extracting'}
<div class="border rounded-lg p-4 bg-gray-50">
<div class="flex items-center gap-3 mb-2">
<div class="animate-spin text-blue-600">🎨</div>
<span class="text-sm font-medium text-gray-700">Extracting thumbnail...</span>
</div>
<!-- Loading skeleton -->
<div class="w-full aspect-square bg-gray-200 animate-pulse rounded"></div>
</div>
{:else if status === 'success' && thumbnail}
<div class="border rounded-lg p-4 bg-white shadow-sm">
<div class="flex items-center gap-2 mb-2">
<span class="text-green-600">✓</span>
<span class="text-sm font-medium text-gray-700">Thumbnail extracted</span>
</div>
<img
src={thumbnail}
alt="Post thumbnail"
class="w-full aspect-square object-cover rounded"
/>
</div>
{:else if status === 'error'}
<div class="border border-red-200 rounded-lg p-4 bg-red-50">
<div class="flex items-center gap-2">
<span class="text-red-600">✗</span>
<span class="text-sm font-medium text-red-700">Thumbnail extraction failed</span>
</div>
</div>
{/if}
Integration with Share Page:
Update the share page to:
- Add thumbnail state:
let thumbnail = $state<string | null>(null) - Add thumbnail status:
let thumbnailStatus = $state<'idle' | 'extracting' | 'success' | 'error'>('idle') - Listen for thumbnail progress events in SSE handler
- Update thumbnail state when event is received
// In the SSE event handler (+page.svelte)
if (event.type === 'thumbnail') {
thumbnail = event.data?.thumbnail || null;
thumbnailStatus = thumbnail ? 'success' : 'error';
logs = [...logs, `🎨 ${event.message}`];
}
Implementation Steps
- Create
src/routes/share/components/ThumbnailPreview.svelte - Implement loading skeleton state
- Implement success state with image display
- Implement error state
- Add responsive styling with TailwindCSS
- Update
+page.svelteto add thumbnail state variables - Add thumbnail event handler to SSE processing
- Integrate component into share page layout
- Set thumbnailStatus to 'extracting' when extraction starts
- Test with real Instagram URLs
Layout Integration
The component should be placed in the share page layout as:
<div class="p-8 max-w-lg mx-auto space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">InstaChef PWA</h1>
<LlmHealthIndicator />
</div>
<UrlInputSection {targetUrl} {sharedText} {sharedUrl} {status} {onProcess} />
<ProgressIndicator {status} />
<!-- Thumbnail Preview - NEW -->
<ThumbnailPreview {thumbnail} status={thumbnailStatus} />
<ExtractedTextViewer {bodyText} />
<RecipeCard {recipe} {tandoorEnabled} {tandoorImporting} {tandoorError} {onRetry} {onImportToTandoor} />
<ErrorState {status} {bodyText} {onRetry} />
<LogViewer {logs} {currentMethod} {status} />
</div>
Testing Strategy
- Test loading state appears immediately when extraction starts
- Test thumbnail appears when event is received
- Test error state when thumbnail extraction fails
- Verify no layout shift when thumbnail loads
- Test responsive behavior on mobile devices
- Verify aspect ratio is maintained
- Test with various image sizes and formats
Files Created
src/routes/share/components/ThumbnailPreview.svelte
Files Modified
src/routes/share/+page.svelte(add thumbnail state and event handling)
User Experience Benefits
- Immediate Feedback: Users see the thumbnail as soon as it's extracted, not waiting for full recipe
- Visual Confirmation: Confirms correct post is being processed
- Progressive Loading: Shows extraction progress step-by-step
- Error Visibility: Clear indication if thumbnail extraction fails
Implementation Order
-
Story 1: Component Refactoring (Foundation)
- Establishes clean component structure
- Makes future changes easier
-
Story 2: LLM Health Indicator (Quick Win)
- Independent of other stories
- Provides immediate user value
-
Story 3: Thumbnail Enhancement (Complex)
- Implements stealth extraction methods
- Adds progress event emission
- Requires thorough testing
-
Story 4: Thumbnail Preview Component (Integration)
- Depends on Story 1 (component structure) and Story 3 (thumbnail events)
- Displays extracted thumbnails in real-time
- Enhances user experience
Dependencies & Prerequisites
External Dependencies
- None (uses existing tech stack)
Internal Dependencies
- Story 2 is independent and can be done anytime
- Story 1 should be completed first (provides component structure)
- Story 3 should be completed before Story 4 (provides thumbnail events)
- Story 4 depends on Story 1 and Story 3
Environment Requirements
- LM Studio running for Story 2 testing
- Valid Instagram URLs for Story 3 testing
Risk Assessment
High Risk
- Instagram Format Changes: Instagram may change their data structures
- Mitigation: Multi-method approach with screenshot fallback
Medium Risk
- Component Refactoring Errors: Breaking existing functionality during split
- Mitigation: Thorough manual testing, incremental migration
Low Risk
- LLM Health Polling Performance: Frequent polling might impact performance
- Mitigation: Configurable interval, can be disabled if needed
Success Metrics
Code Quality
- Share page reduced from 306 to ~100 lines
- Component cohesion: each component < 80 lines
- Test coverage: manual verification of all user flows
User Experience
- LLM status visible before extraction attempt
- Thumbnail extraction success rate > 95%
- No Instagram blocking/detection
Performance
- Page load time unchanged
- Health polling doesn't impact extraction speed
- Thumbnail extraction time reduced (meta tags are faster)
Documentation Updates
Code Documentation
- JSDoc comments for all new functions
- Component prop interfaces documented
- Extraction methods logged for debugging
User Documentation
- No user-facing docs needed (internal feature)
Rollback Plan
Story 1 Rollback
- Revert
+page.svelteto previous version - Delete component files
Story 2 Rollback
- Remove
LlmHealthIndicatorimport from share page - Delete component file
Story 3 Rollback
- Revert extraction.ts changes
- Restore original
extractThumbnail()function - Remove 'thumbnail' from ProgressEventType
Story 4 Rollback
- Remove ThumbnailPreview import from share page
- Remove thumbnail state variables from +page.svelte
- Remove thumbnail event handler from SSE processing
- Delete ThumbnailPreview.svelte component file
Testing Checklist
Story 1: Component Refactoring
- All components render correctly
- State flows from parent to children
- Callbacks trigger parent functions
- Styling matches original
- No console errors
- Extraction flow works end-to-end
Story 2: LLM Health Indicator
- Shows yellow "checking" on load
- Shows green when LLM is healthy
- Shows red when LLM is down
- Polling works (check network tab)
- Cleanup on unmount (no memory leaks)
- Tooltip shows status message
Story 3: Thumbnail Enhancement
- Meta tag extraction works
- Video poster extraction works
- Instagram data extraction works
- Screenshot fallback works
- All methods logged correctly
- No Instagram blocking detected
- Thumbnail progress event is emitted
- Event includes thumbnail data
Story 4: Thumbnail Preview Component
- Loading skeleton shows when extraction starts
- Thumbnail displays when event received
- Error state shows on extraction failure
- No layout shift when thumbnail loads
- Responsive on mobile devices
- Aspect ratio maintained correctly
- Component receives real-time updates
Estimated Total Effort
- Story 1: 4 hours
- Story 2: 2 hours
- Story 3: 6 hours
- Story 4: 2 hours
- Testing & Integration: 2 hours
Total: ~16 hours
Next Steps
Once this plan is approved:
- Create feature branch:
feature/refactor-share-page-thumbnails - Implement stories in order (1 → 2 → 3)
- Test each story before moving to next
- Create pull request with comprehensive testing results
- Deploy to production after approval
References
External Documentation
Internal Documentation
Plan Status: ✅ Ready for Implementation
Developer Command: @dev RefactorSharePageAndEnhanceThumbnails