Files
insta-recipe/patch.js
Giancarmine Salucci dfa2eb1c4e initial commit
2025-11-29 17:34:26 +01:00

410 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
const log = (msg) => console.log(`\x1b[36m[Patch]\x1b[0m ${msg}`);
// --- 1. Fix package.json ---
log('Patching package.json...');
const pkgPath = path.resolve('package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
// FIX: Downgrade Vite to v6 to satisfy PWA/Tailwind peer deps
if (pkg.devDependencies?.vite) {
log(`Create-svelte installed Vite ${pkg.devDependencies.vite}. Downgrading to ^6.0.0 for compatibility.`);
pkg.devDependencies.vite = "^6.0.0";
}
// Add Backend Dependencies
pkg.dependencies = {
...pkg.dependencies,
"zod": "^3.23.0",
"openai": "^4.20.0"
// playwright is already in your devDependencies, which is fine for the service approach
};
// Add PWA Dev Dependency
pkg.devDependencies = {
...pkg.devDependencies,
"@vite-pwa/sveltekit": "^0.3.0"
};
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 4));
log('✅ package.json updated.');
} else {
console.error('❌ package.json not found! Are you in the project root?');
process.exit(1);
}
// --- 2. Inject PWA into vite.config.ts ---
log('Updating vite.config.ts...');
const viteConfigPath = path.resolve('vite.config.ts');
let viteConfig = fs.readFileSync(viteConfigPath, 'utf-8');
// Check if already patched to avoid duplicates
if (!viteConfig.includes('SvelteKitPWA')) {
// Add Import
if (viteConfig.includes('import { sveltekit }')) {
viteConfig = viteConfig.replace(
"import { sveltekit } from '@sveltejs/kit/vite';",
"import { sveltekit } from '@sveltejs/kit/vite';\nimport { SvelteKitPWA } from '@vite-pwa/sveltekit';"
);
}
// Add Plugin (Insert before sveltekit to be safe, or commonly after)
// We look for 'plugins: [' and append the PWA config
const pwaConfig = `
SvelteKitPWA({
srcDir: './src',
mode: 'development',
strategies: 'generateSW',
scope: '/',
base: '/',
selfDestroying: process.env.SELF_DESTROYING_SW === 'true',
manifest: {
short_name: 'InstaChef',
name: 'InstaChef Recipe Saver',
start_url: '/',
scope: '/',
display: 'standalone',
theme_color: "#ffffff",
background_color: "#ffffff",
icons: [
{ src: '/favicon.png', sizes: '192x192', type: 'image/png' },
{ src: '/favicon.png', sizes: '512x512', type: 'image/png' }
],
share_target: {
action: '/share',
method: 'GET',
enctype: 'application/x-www-form-urlencoded',
params: { title: 'title', text: 'text', url: 'url' }
}
},
workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}']
},
devOptions: {
enabled: true,
suppressWarnings: true,
navigateFallback: '/',
},
}),`;
// Insert inside plugins array
viteConfig = viteConfig.replace('plugins: [', `plugins: [${pwaConfig}`);
fs.writeFileSync(viteConfigPath, viteConfig);
log('✅ vite.config.ts updated with PWA settings.');
} else {
log(' vite.config.ts already contains PWA settings. Skipping.');
}
// --- 3. Create Backend & Docker Files ---
// We write these strictly to avoid overwriting your existing src/ files
// (except for the new API routes)
const newFiles = {
// Docker Composition
'docker-compose.yml': `
services:
app:
build: .
ports:
- "5173:5173"
environment:
- PLAYWRIGHT_WS_ENDPOINT=ws://playwright-service:3000
- OPENAI_BASE_URL=http://ollama:11434/v1
- OPENAI_API_KEY=ollama
- LLM_MODEL=llama3.2
volumes:
- ./src:/app/src
- ./secrets:/app/secrets:ro
depends_on:
- playwright-service
- ollama
playwright-service:
build: ./playwright-service
ipc: host
ports: ["3000:3000"]
environment:
- DISPLAY=:99
security_opt:
- seccomp=unconfined
ollama:
image: ollama/ollama:latest
ports: ["11434:11434"]
volumes:
- ollama_data:/root/.ollama
volumes:
ollama_data:
`,
// Dockerfile for SvelteKit
'Dockerfile': `
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
`,
// Playwright Service
'playwright-service/Dockerfile': `
FROM mcr.microsoft.com/playwright:v1.49.0-jammy
WORKDIR /app
RUN npm init -y && npm install playwright
COPY server.js .
EXPOSE 3000
CMD ["node", "server.js"]
`,
'playwright-service/server.js': `
const { chromium } = require('playwright');
(async () => {
const server = await chromium.launchServer({
port: 3000,
headless: true,
args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']
});
console.log('Browser Server running on port 3000...');
await new Promise(() => {});
})();
`,
// Auth Generator Script (Updated to use imports since project is type: module)
'scripts/gen-auth.js': `
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';
(async () => {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
console.log('🔹 Navigating to Instagram...');
await page.goto('https://www.instagram.com/');
console.log('⏳ Please log in manually. Waiting for "Home" icon...');
try {
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 120000 });
const secretsDir = path.resolve('secrets');
if (!fs.existsSync(secretsDir)) fs.mkdirSync(secretsDir);
await context.storageState({ path: path.join(secretsDir, 'auth.json') });
console.log('🎉 Session saved to secrets/auth.json');
} catch (e) {
console.error('❌ Timeout or error:', e);
}
await browser.close();
})();
`,
// Logic: LLM Client
'src/lib/server/llm.ts': `
import OpenAI from 'openai';
import { env } from '$env/dynamic/private';
export const createLLM = () => {
// Detect if we are using Ollama or OpenAI based on URL
const baseURL = env.OPENAI_BASE_URL;
const client = new OpenAI({
apiKey: env.OPENAI_API_KEY,
baseURL: baseURL
});
return { client, model: env.LLM_MODEL || 'gpt-4o' };
};
`,
// Logic: API Endpoint
'src/routes/api/extract/+server.ts': `
import { json } from '@sveltejs/kit';
import { createLLM } from '$lib/server/llm';
import { z } from 'zod';
import { zodResponseFormat } from 'openai/helpers/zod';
import { chromium } from 'playwright';
import fs from 'fs';
import { env } from '$env/dynamic/private';
const RecipeSchema = z.object({
name: z.string(),
description: z.string(),
steps: z.array(z.string()),
ingredients: z.array(z.object({
item: z.string(),
amount: z.string(),
unit: z.string()
}))
});
export async function POST({ request }) {
const { url } = await request.json();
// 1. Browser Connection
// Fallback to localhost if env var not set (e.g. running outside docker)
const wsEndpoint = env.PLAYWRIGHT_WS_ENDPOINT || 'ws://127.0.0.1:3000';
console.log('Connecting to browser at:', wsEndpoint);
const browser = await chromium.connect(wsEndpoint);
// 2. Load Auth if available
const authPath = '/app/secrets/auth.json';
let context;
// We check absolute path (Docker) or relative (Local)
if (fs.existsSync(authPath)) {
context = await browser.newContext({ storageState: authPath });
} else if (fs.existsSync('./secrets/auth.json')) {
context = await browser.newContext({ storageState: './secrets/auth.json' });
} else {
console.warn('No auth.json found. Running as guest.');
context = await browser.newContext();
}
const page = await context.newPage();
let bodyText = '';
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
// Naive scraper attempt
bodyText = await page.evaluate(() => document.body.innerText);
} catch (e) {
console.error('Scraping error:', e);
return json({ error: 'Failed to scrape URL' }, { status: 500 });
} finally {
await page.close();
await context.close();
await browser.close();
}
// 3. LLM Processing
try {
const { client, model } = createLLM();
const completion = await client.beta.chat.completions.parse({
model,
messages: [
{ role: "system", content: "Extract a recipe structure from this text. If it is not a recipe, return empty arrays." },
{ role: "user", content: bodyText.substring(0, 8000) } // Limit context window
],
response_format: zodResponseFormat(RecipeSchema, "recipe")
});
return json({ recipe: completion.choices[0].message.parsed });
} catch (e) {
console.error('LLM error:', e);
return json({ error: 'Failed to parse recipe' }, { status: 500 });
}
}
`,
// UI: Share Target Page
'src/routes/share/+page.svelte': `
<script lang="ts">
import { page } from '$app/stores';
let status = $state('idle');
let logs = $state<string[]>([]);
let recipe = $state<any>(null);
// URL param parsing for Share Target
// Instagram typically shares text that contains the URL, so we might need to parse it out
let sharedText = $derived($page.url.searchParams.get('text') || '');
let sharedUrl = $derived($page.url.searchParams.get('url') || '');
function extractUrl(text: string) {
const match = text.match(/(https?:\\/\\/[^\\s]+)/);
return match ? match[0] : null;
}
let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
async function process() {
if(!targetUrl) return;
status = 'extracting';
logs = [...logs, 'Sending to server... ' + targetUrl];
try {
const res = await fetch('/api/extract', {
method: 'POST',
body: JSON.stringify({ url: targetUrl }),
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.recipe) {
recipe = data.recipe;
status = 'done';
} else {
logs = [...logs, 'Error: ' + JSON.stringify(data)];
status = 'error';
}
} catch(e) {
logs = [...logs, 'Network Error'];
status = 'error';
}
}
</script>
<div class="p-8 max-w-lg mx-auto space-y-4">
<h1 class="text-2xl font-bold">InstaChef PWA</h1>
{#if targetUrl}
<div class="bg-gray-100 p-2 rounded break-all text-sm border">{targetUrl}</div>
{#if status === 'idle'}
<button onclick={process} class="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700 w-full">
Extract Recipe
</button>
{/if}
{:else}
<p class="text-gray-500">No URL detected. Open this app via Instagram Share Menu.</p>
<div class="text-xs text-gray-400">Debug: Text={sharedText} URL={sharedUrl}</div>
{/if}
{#if status === 'extracting'}
<div class="animate-pulse text-blue-600">Extracting data...</div>
{/if}
{#if recipe}
<div class="border rounded p-4 bg-green-50 space-y-2">
<h2 class="font-bold text-xl">{recipe.name}</h2>
<p class="text-sm">{recipe.description}</p>
<h3 class="font-bold mt-2">Ingredients</h3>
<ul class="list-disc pl-5 text-sm">
{#each recipe.ingredients as ing}
<li>{ing.amount} {ing.unit} {ing.item}</li>
{/each}
</ul>
</div>
{/if}
<div class="font-mono text-xs bg-slate-900 text-green-400 p-4 rounded min-h-[100px] mt-8">
<div class="opacity-50 border-b border-slate-700 mb-2">System Logs</div>
{#each logs as l}<div>> {l}</div>{/each}
</div>
</div>
`
};
log('Writing service files...');
// Ensure dirs
['playwright-service', 'scripts', 'src/lib/server', 'src/routes/api/extract', 'src/routes/share', 'secrets'].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
for (const [filepath, content] of Object.entries(newFiles)) {
// Only write if file doesn't exist to avoid destroying user work
// EXCEPT for the new API routes which we know are new
if (!fs.existsSync(filepath) || filepath.includes('src/routes/api') || filepath.includes('src/lib/server')) {
fs.writeFileSync(path.resolve(filepath), content.trim());
log(`Created: ${filepath}`);
} else {
log(`Skipped (Exists): ${filepath}`);
}
}
log('✅ Patch complete. Run "npm install" now.');