Version One
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
39
README.md
39
README.md
@@ -1,2 +1,39 @@
|
||||
# Recipe-AI
|
||||
Recipe-AI Web
|
||||
==============
|
||||
|
||||
This app takes a TikTok or Instagram share link, resolves it to a direct video, uploads the video to OpenAI, and asks the model to return only:
|
||||
|
||||
- Ingredients
|
||||
- Prep steps
|
||||
- Cooking steps
|
||||
|
||||
It uses environment variables for keys and optional RapidAPI resolvers.
|
||||
|
||||
requires ffmpeg on server
|
||||
|
||||
## Getting Started
|
||||
|
||||
1) Create your environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# set OPENAI_API_KEY, and optional RAPIDAPI_* values
|
||||
```
|
||||
|
||||
2) Run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000/recipe](http://localhost:3000/recipe) to use the analyzer.
|
||||
|
||||
### API
|
||||
|
||||
- `POST /api/analyze` with JSON body `{ "url": "<share link>" }` returns `{ platform, analysis }` where `analysis` contains `ingredients`, `prep_steps`, `cooking_steps`.
|
||||
|
||||
### Notes
|
||||
|
||||
- The app includes first-party scrapers for TikTok and Instagram (HTML parsing of embedded JSON/meta). No RapidAPI is required.
|
||||
- Resolution of direct video URLs is still best-effort; platforms change often. RapidAPI is optional as a fallback.
|
||||
- OpenAI costs apply for file uploads and analysis; keep videos short.
|
||||
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6703
package-lock.json
generated
Normal file
6703
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "recipe-ai",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.0.0",
|
||||
"openai": "^6.7.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"mysql2": "^3.11.3",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
134
src/app/api/analyze/route.ts
Normal file
134
src/app/api/analyze/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { resolveDirectVideoUrl } from '@/lib/resolvers';
|
||||
import { analyzeCookingThumbnails, analyzeFromTranscript, analyzeFromTranscriptAndImages, transcribeAudioBytes, generateRecipeTitle } from '@/lib/openai';
|
||||
import { downloadInstagramVideoBytes } from '@/lib/instaloader';
|
||||
import { downloadTikTokVideoBytes } from '@/lib/pyktok';
|
||||
import { extractThumbnailsFromVideoBytes } from '@/lib/video';
|
||||
import { extractMp3FromVideoBytes } from '@/lib/audio';
|
||||
import { unlink } from 'node:fs/promises';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60; // seconds
|
||||
|
||||
const BodySchema = z.object({ url: z.string().url() });
|
||||
|
||||
async function downloadBytes(url: string): Promise<Uint8Array> {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123 Safari/537.36',
|
||||
accept: '*/*',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download video: ${res.status}`);
|
||||
}
|
||||
const ab = await res.arrayBuffer();
|
||||
return new Uint8Array(ab);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const json = await req.json();
|
||||
const { url } = BodySchema.parse(json);
|
||||
|
||||
const resolved = await resolveDirectVideoUrl(url);
|
||||
let uploadBytes: Uint8Array | null = null;
|
||||
let description: string = '';
|
||||
let tempFilePath: string | null = null;
|
||||
if (resolved.directUrl) {
|
||||
uploadBytes = await downloadBytes(resolved.directUrl);
|
||||
} else if (resolved.sourcePlatform === 'instagram') {
|
||||
// Fallback: use Instaloader helper
|
||||
const ig = await downloadInstagramVideoBytes(url);
|
||||
if (ig) {
|
||||
uploadBytes = ig.bytes;
|
||||
description = ig.description || '';
|
||||
tempFilePath = ig.filePath || null;
|
||||
}
|
||||
} else if (resolved.sourcePlatform === 'tiktok') {
|
||||
// Fallback: use Pyktok helper
|
||||
const tt = await downloadTikTokVideoBytes(url);
|
||||
if (tt) {
|
||||
uploadBytes = tt.bytes;
|
||||
description = tt.description || '';
|
||||
tempFilePath = tt.filePath || null;
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not resolve a direct video URL from the provided link.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!uploadBytes) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not obtain video bytes from the provided link.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Convert to transcript first
|
||||
let analysis;
|
||||
let transcript: string | null = null;
|
||||
try {
|
||||
const audio = await extractMp3FromVideoBytes(uploadBytes);
|
||||
if (audio && audio.byteLength > 0) {
|
||||
transcript = await transcribeAudioBytes(audio);
|
||||
if (transcript && transcript.trim().length > 0) {
|
||||
// Extract frames at 2 fps and include alongside transcript in order
|
||||
const thumbsForCombined = await extractThumbnailsFromVideoBytes(uploadBytes, 0, 2);
|
||||
if (thumbsForCombined.length) {
|
||||
analysis = await analyzeFromTranscriptAndImages(transcript, thumbsForCombined, description);
|
||||
} else {
|
||||
const combined = description && description.trim()
|
||||
? `Caption/Description: ${description}\n\nTranscript: ${transcript}`
|
||||
: transcript;
|
||||
analysis = await analyzeFromTranscript(combined);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall back to thumbnails below
|
||||
}
|
||||
|
||||
// 2) If transcript failed/empty, fall back to thumbnails-based analysis
|
||||
if (!analysis) {
|
||||
const thumbs = await extractThumbnailsFromVideoBytes(uploadBytes, 0, 2);
|
||||
if (!thumbs.length) {
|
||||
return NextResponse.json({ error: 'Could not extract thumbnails from video' }, { status: 400 });
|
||||
}
|
||||
analysis = await analyzeCookingThumbnails(thumbs, description);
|
||||
}
|
||||
|
||||
// Provide first thumbnail base64 for saving
|
||||
const firstThumbArr = await extractThumbnailsFromVideoBytes(uploadBytes, 1, 2);
|
||||
const firstThumbBase64 = firstThumbArr[0]
|
||||
? `data:image/jpeg;base64,${Buffer.from(firstThumbArr[0]).toString('base64')}`
|
||||
: undefined;
|
||||
|
||||
// Generate a title
|
||||
const title = await generateRecipeTitle({ description, transcript: transcript || '', analysis });
|
||||
|
||||
// Cleanup: delete temp downloaded video file if present
|
||||
if (tempFilePath) {
|
||||
try { await unlink(tempFilePath); } catch {}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
platform: resolved.sourcePlatform,
|
||||
title,
|
||||
description,
|
||||
transcript: transcript || '',
|
||||
thumbnailBase64: firstThumbBase64,
|
||||
analysis,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || 'Unexpected error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
src/app/api/debug-resolve/route.ts
Normal file
33
src/app/api/debug-resolve/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { resolveDirectVideoUrl } from '@/lib/resolvers';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BodySchema = z.object({ url: z.string().url() });
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const json = await req.json();
|
||||
const { url } = BodySchema.parse(json);
|
||||
const resolved = await resolveDirectVideoUrl(url);
|
||||
return NextResponse.json(resolved);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || 'Unexpected error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const url = searchParams.get('url');
|
||||
if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||
const resolved = await resolveDirectVideoUrl(url);
|
||||
return NextResponse.json(resolved);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || 'Unexpected error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
src/app/api/folders/route.ts
Normal file
34
src/app/api/folders/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { ensureSchema, getDb } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const CreateBody = z.object({ name: z.string().min(1).max(255) });
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const db = getDb();
|
||||
const [rows] = await db.query('SELECT id, name, created_at FROM folders ORDER BY name ASC');
|
||||
return NextResponse.json({ folders: rows });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || 'Unexpected error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const json = await req.json();
|
||||
const body = CreateBody.parse(json);
|
||||
const db = getDb();
|
||||
const [res] = await db.execute('INSERT INTO folders (name) VALUES (?)', [body.name]);
|
||||
return NextResponse.json({ id: (res as any).insertId, name: body.name });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || 'Unexpected error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
src/app/api/recipes/[id]/route.ts
Normal file
44
src/app/api/recipes/[id]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { ensureSchema, getDb } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const UpdateBody = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
analysis: z.any().optional(),
|
||||
folderId: z.number().int().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const { id } = await ctx.params;
|
||||
const idNum = Number(id);
|
||||
if (!idNum || Number.isNaN(idNum)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
const json = await req.json();
|
||||
const body = UpdateBody.parse(json);
|
||||
const db = getDb();
|
||||
|
||||
// Build dynamic update
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
if (body.title !== undefined) { fields.push('title = ?'); values.push(body.title); }
|
||||
if (body.description !== undefined) { fields.push('description = ?'); values.push(body.description); }
|
||||
if (body.transcript !== undefined) { fields.push('transcript = ?'); values.push(body.transcript); }
|
||||
if (body.analysis !== undefined) { fields.push('analysis_json = ?'); values.push(JSON.stringify(body.analysis)); }
|
||||
if (body.folderId !== undefined) { fields.push('folder_id = ?'); values.push(body.folderId); }
|
||||
if (!fields.length) return NextResponse.json({ ok: true });
|
||||
|
||||
values.push(idNum);
|
||||
await db.execute(`UPDATE recipes SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || 'Unexpected error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
65
src/app/api/recipes/route.ts
Normal file
65
src/app/api/recipes/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { ensureSchema, getDb } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const SaveBody = z.object({
|
||||
url: z.string().url(),
|
||||
platform: z.enum(['tiktok', 'instagram', 'unknown']),
|
||||
title: z.string().optional().default(''),
|
||||
description: z.string().optional().default(''),
|
||||
transcript: z.string().optional().default(''),
|
||||
analysis: z.any(),
|
||||
thumbnailBase64: z.string().optional(),
|
||||
folderId: z.number().int().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const db = getDb();
|
||||
const [rows] = await db.query(
|
||||
'SELECT id, created_at, source_url, platform, description, transcript, CAST(analysis_json AS CHAR) AS analysis_json, thumbnail FROM recipes ORDER BY id DESC LIMIT 100',
|
||||
);
|
||||
const list = (rows as any[]).map((r) => ({
|
||||
id: r.id,
|
||||
created_at: r.created_at,
|
||||
url: r.source_url,
|
||||
platform: r.platform,
|
||||
description: r.description,
|
||||
transcript: r.transcript,
|
||||
analysis: (() => { try { return JSON.parse(r.analysis_json); } catch { return null; } })(),
|
||||
thumbnailBase64: r.thumbnail ? `data:image/jpeg;base64,${Buffer.from(r.thumbnail).toString('base64')}` : null,
|
||||
}));
|
||||
return NextResponse.json({ recipes: list });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || 'Unexpected error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const json = await req.json();
|
||||
const body = SaveBody.parse(json);
|
||||
|
||||
const db = getDb();
|
||||
const thumbBuf = body.thumbnailBase64?.startsWith('data:')
|
||||
? Buffer.from(body.thumbnailBase64.split(',')[1] || '', 'base64')
|
||||
: body.thumbnailBase64
|
||||
? Buffer.from(body.thumbnailBase64, 'base64')
|
||||
: null;
|
||||
const [res] = await db.execute(
|
||||
'INSERT INTO recipes (source_url, platform, title, description, transcript, analysis_json, thumbnail, folder_id) VALUES (?,?,?,?,?,?,?,?)',
|
||||
[body.url, body.platform, body.title, body.description, body.transcript, JSON.stringify(body.analysis), thumbBuf, body.folderId ?? null],
|
||||
);
|
||||
const id = (res as any).insertId;
|
||||
return NextResponse.json({ id });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || 'Unexpected error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
src/app/globals.css
Normal file
26
src/app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
34
src/app/layout.tsx
Normal file
34
src/app/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
88
src/app/page.tsx
Normal file
88
src/app/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ensureSchema, getDb } from '@/lib/db';
|
||||
import FoldersBar from './shared/FoldersBar';
|
||||
|
||||
type RecipeRow = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
source_url: string;
|
||||
platform: string;
|
||||
description: string | null;
|
||||
transcript: string | null;
|
||||
analysis_json: string;
|
||||
thumbnail: Buffer | null;
|
||||
};
|
||||
|
||||
export default async function Home() {
|
||||
await ensureSchema();
|
||||
const db = getDb();
|
||||
const [rows] = await db.query(
|
||||
'SELECT id, created_at, source_url, platform, description, transcript, CAST(analysis_json AS CHAR) AS analysis_json, thumbnail FROM recipes ORDER BY id DESC LIMIT 100',
|
||||
);
|
||||
const [foldersRows] = await db.query('SELECT id, name FROM folders ORDER BY name ASC');
|
||||
const recipes = (rows as RecipeRow[]).map((r) => ({
|
||||
id: r.id,
|
||||
created_at: r.created_at,
|
||||
url: r.source_url,
|
||||
platform: r.platform,
|
||||
title: (r as any).title || null,
|
||||
folder_id: (r as any).folder_id || null,
|
||||
thumbnailBase64: r.thumbnail ? `data:image/jpeg;base64,${Buffer.from(r.thumbnail).toString('base64')}` : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-6 md:p-12 lg:p-16 flex flex-col gap-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl md:text-4xl font-bold">Saved Recipes</h1>
|
||||
<a href="/recipe" className="underline">Analyze new video →</a>
|
||||
</div>
|
||||
<FoldersBar initialFolders={(foldersRows as any[]).map((f) => ({ id: f.id, name: f.name }))} />
|
||||
{/* Folders sections */}
|
||||
{(foldersRows as any[]).map((f: any) => (
|
||||
<section key={f.id} id={`folder-${f.id}`} className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">{f.name}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{recipes.filter((r) => r.folder_id === f.id).map((r) => (
|
||||
<a key={r.id} href={`/recipes/${r.id}`} className="border rounded overflow-hidden hover:shadow">
|
||||
{r.thumbnailBase64 ? (
|
||||
<img src={r.thumbnailBase64} alt="thumb" className="w-full h-48 object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-48 bg-neutral-200 dark:bg-neutral-800" />
|
||||
)}
|
||||
<div className="p-3 text-sm">
|
||||
<div className="font-medium">{r.title || r.url}</div>
|
||||
<div className="text-neutral-500">{new Date(r.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
{!recipes.some((r) => r.folder_id === f.id) && (
|
||||
<div className="text-neutral-600 dark:text-neutral-300">No recipes in this folder yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Unsorted */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">Unsorted</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{recipes.filter((r) => !r.folder_id).map((r) => (
|
||||
<a key={r.id} href={`/recipes/${r.id}`} className="border rounded overflow-hidden hover:shadow">
|
||||
{r.thumbnailBase64 ? (
|
||||
<img src={r.thumbnailBase64} alt="thumb" className="w-full h-48 object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-48 bg-neutral-200 dark:bg-neutral-800" />
|
||||
)}
|
||||
<div className="p-3 text-sm">
|
||||
<div className="font-medium">{r.title || r.url}</div>
|
||||
<div className="text-neutral-500">{new Date(r.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
{!recipes.length && (
|
||||
<div className="text-neutral-600 dark:text-neutral-300">No recipes saved yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
180
src/app/recipe/page.tsx
Normal file
180
src/app/recipe/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type Analysis = {
|
||||
platform: 'tiktok' | 'instagram' | 'unknown';
|
||||
analysis: {
|
||||
ingredients: Array<{ name: string; quantity: string | null; unit: string | null; notes?: string | null }>;
|
||||
prep_steps: string[];
|
||||
cooking_steps: string[];
|
||||
};
|
||||
description?: string;
|
||||
transcript?: string;
|
||||
thumbnailBase64?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export default function RecipeAnalyzerPage() {
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<Analysis | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const canSave = !!result && !!url;
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch('/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed');
|
||||
setResult(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Something went wrong');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!result) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/recipes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
platform: result.platform,
|
||||
title: result.title || '',
|
||||
description: result.description || '',
|
||||
transcript: result.transcript || '',
|
||||
analysis: result.analysis,
|
||||
thumbnailBase64: result.thumbnailBase64,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed to save');
|
||||
alert('Saved!');
|
||||
} catch (err: any) {
|
||||
alert(err?.message || 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-6 md:p-12 lg:p-16 flex flex-col gap-8 max-w-3xl mx-auto">
|
||||
<h1 className="text-3xl md:text-4xl font-bold">Recipe AI from Reels/TikToks</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-300">
|
||||
Paste a TikTok or Instagram share link. The AI analyzes only the video to extract an ingredient list, prep steps, and cooking steps.
|
||||
</p>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://www.tiktok.com/@user/video/123... or https://www.instagram.com/reel/..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="w-full rounded border border-neutral-300 dark:border-neutral-700 bg-transparent px-3 py-2"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center rounded bg-black text-white dark:bg-white dark:text-black px-4 py-2 disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Analyzing…' : 'Analyze Video'}
|
||||
</button>
|
||||
{result && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center justify-center rounded border border-neutral-300 dark:border-neutral-700 px-4 py-2 disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Recipe'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-300 bg-red-50 text-red-800 dark:bg-red-950 dark:text-red-200 p-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={result.title || ''}
|
||||
onChange={(e) => setResult({ ...result, title: e.target.value })}
|
||||
className="w-full rounded border border-neutral-300 dark:border-neutral-700 bg-transparent px-3 py-2"
|
||||
placeholder="Recipe title"
|
||||
/>
|
||||
</div>
|
||||
{result.thumbnailBase64 && (
|
||||
<img src={result.thumbnailBase64} alt="thumbnail" className="w-full h-auto rounded" />
|
||||
)}
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300 break-words">
|
||||
<div>
|
||||
<span className="font-medium">Source:</span>{' '}
|
||||
<a href={url} className="underline" target="_blank" rel="noreferrer">
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
{result.description && (
|
||||
<div className="mt-1 whitespace-pre-wrap">
|
||||
<span className="font-medium">Description:</span> {result.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Ingredients</h2>
|
||||
<ul className="list-disc pl-6 mt-2 space-y-1">
|
||||
{result.analysis.ingredients.map((it, idx) => (
|
||||
<li key={idx}>
|
||||
<span className="font-medium">{it.name}</span>
|
||||
{it.quantity ? ` – ${it.quantity}` : ''}
|
||||
{it.unit ? ` ${it.unit}` : ''}
|
||||
{it.notes ? ` (${it.notes})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Prep Steps</h2>
|
||||
<ol className="list-decimal pl-6 mt-2 space-y-1">
|
||||
{result.analysis.prep_steps.map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Cooking Steps</h2>
|
||||
<ol className="list-decimal pl-6 mt-2 space-y-1">
|
||||
{result.analysis.cooking_steps.map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
144
src/app/recipes/[id]/Editor.tsx
Normal file
144
src/app/recipes/[id]/Editor.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
import { useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
transcript: string;
|
||||
analysis: { ingredients: any[]; prep_steps: string[]; cooking_steps: string[] };
|
||||
folders: Array<{ id: number; name: string }>;
|
||||
folderId: number | null;
|
||||
};
|
||||
|
||||
export default function Editor({ id, title, description, transcript, analysis, folders, folderId }: Props) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [t, setT] = useState(title || '');
|
||||
const [desc, setDesc] = useState(description || '');
|
||||
const [trans, setTrans] = useState(transcript || '');
|
||||
const [ingText, setIngText] = useState((analysis?.ingredients || []).map((i: any) => `${i.quantity ?? ''} ${i.unit ?? ''} ${i.name}`.trim()).join('\n'));
|
||||
const [prepText, setPrepText] = useState((analysis?.prep_steps || []).join('\n'));
|
||||
const [cookText, setCookText] = useState((analysis?.cooking_steps || []).join('\n'));
|
||||
const [folder, setFolder] = useState<number | null>(folderId ?? null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const newAnalysis = {
|
||||
ingredients: ingText.split(/\r?\n/).map((l) => ({ name: l.trim(), quantity: null, unit: null })).filter((x) => x.name),
|
||||
prep_steps: prepText.split(/\r?\n/).map((s) => s.trim()).filter(Boolean),
|
||||
cooking_steps: cookText.split(/\r?\n/).map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
const res = await fetch(`/api/recipes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: t, description: desc, transcript: trans, analysis: newAnalysis, folderId: folder }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed to save');
|
||||
alert('Saved');
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{desc && (
|
||||
<div className="whitespace-pre-wrap text-neutral-700 dark:text-neutral-300"><span className="font-medium">Description:</span> {desc}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Ingredients</h2>
|
||||
<ul className="list-disc pl-6 mt-2 space-y-1">
|
||||
{(analysis?.ingredients || []).map((it: any, idx: number) => (
|
||||
<li key={idx}>
|
||||
<span className="font-medium">{it.name}</span>
|
||||
{it.quantity ? ` – ${it.quantity}` : ''}
|
||||
{it.unit ? ` ${it.unit}` : ''}
|
||||
{it.notes ? ` (${it.notes})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Prep Steps</h2>
|
||||
<ol className="list-decimal pl-6 mt-2 space-y-1">
|
||||
{(analysis?.prep_steps || []).map((s: string, i: number) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Cooking Steps</h2>
|
||||
<ol className="list-decimal pl-6 mt-2 space-y-1">
|
||||
{(analysis?.cooking_steps || []).map((s: string, i: number) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{trans && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer font-medium">Transcript</summary>
|
||||
<pre className="whitespace-pre-wrap text-sm mt-2">{trans}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<button onClick={() => setIsEditing(true)} className="inline-flex items-center justify-center rounded border border-neutral-300 dark:border-neutral-700 px-4 py-2">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Title</label>
|
||||
<input className="w-full rounded border border-neutral-300 dark:border-neutral-700 bg-transparent px-3 py-2" value={t} onChange={(e) => setT(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Folder</label>
|
||||
<select className="w-full rounded border border-neutral-300 dark:border-neutral-700 bg-transparent px-3 py-2" value={folder ?? ''} onChange={(e) => setFolder(e.target.value ? Number(e.target.value) : null)}>
|
||||
<option value="">Unsorted</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.id}>{f.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Description</label>
|
||||
<textarea className="w-full rounded border border-neutral-300 dark:border-neutral-700 bg-transparent px-3 py-2" rows={3} value={desc} onChange={(e) => setDesc(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Ingredients (one per line)</label>
|
||||
<textarea className="w-full rounded border border-neutral-300 dark:border-neutral-700 bg-transparent px-3 py-2" rows={6} value={ingText} onChange={(e) => setIngText(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Prep Steps (one per line)</label>
|
||||
<textarea className="w-full rounded border border-neutral-300 dark:border-neutral-700 bg-transparent px-3 py-2" rows={6} value={prepText} onChange={(e) => setPrepText(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Cooking Steps (one per line)</label>
|
||||
<textarea className="w-full rounded border border-neutral-300 dark:border-neutral-700 bg-transparent px-3 py-2" rows={6} value={cookText} onChange={(e) => setCookText(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Transcript</label>
|
||||
<textarea className="w-full rounded border border-neutral-300 dark:border-neutral-700 bg-transparent px-3 py-2" rows={6} value={trans} onChange={(e) => setTrans(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={save} disabled={saving} className="inline-flex items-center justify-center rounded bg-black text-white dark:bg.white dark:text-black px-4 py-2 disabled:opacity-60">{saving ? 'Saving…' : 'Save Changes'}</button>
|
||||
<button onClick={() => setIsEditing(false)} className="inline-flex items-center justify-center rounded border border-neutral-300 dark:border-neutral-700 px-4 py-2">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
78
src/app/recipes/[id]/page.tsx
Normal file
78
src/app/recipes/[id]/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ensureSchema, getDb } from '@/lib/db';
|
||||
import Editor from './Editor';
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
type RecipeRow = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
source_url: string;
|
||||
platform: 'tiktok' | 'instagram' | 'unknown';
|
||||
description: string | null;
|
||||
transcript: string | null;
|
||||
analysis_json: string;
|
||||
thumbnail: Buffer | null;
|
||||
};
|
||||
|
||||
export default async function RecipeDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const idNum = Number(id);
|
||||
if (!idNum || Number.isNaN(idNum)) {
|
||||
return (
|
||||
<main className="min-h-screen p-6 md:p-12 lg:p-16 max-w-3xl mx-auto">
|
||||
<div className="text-red-600">Invalid recipe id</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
await ensureSchema();
|
||||
const db = getDb();
|
||||
const [rows] = await db.query(
|
||||
'SELECT id, created_at, source_url, platform, description, transcript, CAST(analysis_json AS CHAR) AS analysis_json, thumbnail FROM recipes WHERE id = ? LIMIT 1',
|
||||
[idNum],
|
||||
);
|
||||
const [foldersRows] = await db.query('SELECT id, name FROM folders ORDER BY name ASC');
|
||||
const row = (rows as RecipeRow[])[0];
|
||||
if (!row) {
|
||||
return (
|
||||
<main className="min-h-screen p-6 md:p-12 lg:p-16 max-w-3xl mx-auto">
|
||||
<div className="text-neutral-700 dark:text-neutral-300">Recipe not found.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const analysis = (() => { try { return JSON.parse(row.analysis_json); } catch { return null; } })();
|
||||
const thumb = row.thumbnail ? `data:image/jpeg;base64,${Buffer.from(row.thumbnail).toString('base64')}` : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-6 md:p-12 lg:p-16 flex flex-col gap-6 max-w-3xl mx-auto">
|
||||
<a href="/" className="underline">← Back to saved recipes</a>
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl font-bold">{row.title || `Recipe #${row.id}`}</h1>
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
<span className="capitalize font-medium">{row.platform}</span>{' '}•{' '}
|
||||
<a href={row.source_url} target="_blank" rel="noreferrer" className="underline">Open source link</a>{' '}•{' '}
|
||||
{new Date(row.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{thumb && <img src={thumb} alt="thumbnail" className="w-full h-auto rounded" />}
|
||||
|
||||
{row.description && (
|
||||
<div className="whitespace-pre-wrap text-neutral-700 dark:text-neutral-300"><span className="font-medium">Description:</span> {row.description}</div>
|
||||
)}
|
||||
|
||||
<Editor
|
||||
id={row.id}
|
||||
title={row.title || ''}
|
||||
description={row.description || ''}
|
||||
transcript={row.transcript || ''}
|
||||
analysis={analysis || { ingredients: [], prep_steps: [], cooking_steps: [] }}
|
||||
folders={(foldersRows as any[]).map((f) => ({ id: f.id, name: f.name }))}
|
||||
folderId={(row as any).folder_id ?? null}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
3
src/app/recipes/page.tsx
Normal file
3
src/app/recipes/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from '../recipe/page';
|
||||
|
||||
|
||||
49
src/app/shared/FoldersBar.tsx
Normal file
49
src/app/shared/FoldersBar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
import { useState } from 'react';
|
||||
|
||||
type Folder = { id: number; name: string };
|
||||
|
||||
export default function FoldersBar({ initialFolders }: { initialFolders: Folder[] }) {
|
||||
const [folders, setFolders] = useState<Folder[]>(initialFolders);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
async function createFolder() {
|
||||
const name = prompt('Folder name');
|
||||
if (!name) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch('/api/folders', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed to create folder');
|
||||
setFolders([...folders, { id: data.id, name: data.name }].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Failed to create folder');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!folders.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b pb-3">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">No folders yet</div>
|
||||
<button onClick={createFolder} disabled={creating} className="text-sm underline">{creating ? 'Creating…' : 'New Folder'}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b pb-3">
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{folders.map((f) => (
|
||||
<a key={f.id} href={`/#folder-${f.id}`} className="text-sm px-3 py-1 rounded-full border hover:bg-neutral-50 dark:hover:bg-neutral-900">
|
||||
{f.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={createFolder} disabled={creating} className="text-sm underline">{creating ? 'Creating…' : 'New Folder'}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
31
src/lib/audio.ts
Normal file
31
src/lib/audio.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { mkdtemp, writeFile, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile as _execFile } from 'node:child_process';
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
export async function extractMp3FromVideoBytes(videoBytes: Uint8Array): Promise<Uint8Array | null> {
|
||||
const tmpBase = await mkdtemp(join(tmpdir(), 'recipe-ai-audio-'));
|
||||
const inputPath = join(tmpBase, 'input.mp4');
|
||||
const outputPath = join(tmpBase, 'audio.mp3');
|
||||
await writeFile(inputPath, videoBytes);
|
||||
try {
|
||||
await execFile('ffmpeg', [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-vn',
|
||||
'-ar', '44100',
|
||||
'-ac', '1',
|
||||
'-b:a', '128k',
|
||||
outputPath,
|
||||
], { timeout: 30_000 });
|
||||
const buf = await readFile(outputPath);
|
||||
return new Uint8Array(buf);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
58
src/lib/db.ts
Normal file
58
src/lib/db.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
let pool: mysql.Pool | null = null;
|
||||
|
||||
export function getDb() {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
host: process.env.db_ip || '127.0.0.1',
|
||||
port: Number(process.env.db_port || 3306),
|
||||
user: process.env.db_username || 'recipe',
|
||||
password: process.env.db_password || 'pass',
|
||||
database: process.env.db_name || 'recipe_db',
|
||||
connectionLimit: 10,
|
||||
charset: 'utf8mb4',
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function ensureSchema() {
|
||||
const db = getDb();
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
source_url VARCHAR(1024) NOT NULL,
|
||||
platform ENUM('tiktok','instagram','unknown') NOT NULL,
|
||||
description TEXT NULL,
|
||||
transcript MEDIUMTEXT NULL,
|
||||
analysis_json JSON NOT NULL,
|
||||
thumbnail LONGBLOB NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
// folders table
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
name VARCHAR(255) NOT NULL UNIQUE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
// Add title column to recipes if missing
|
||||
try {
|
||||
await db.execute(`ALTER TABLE recipes ADD COLUMN title VARCHAR(255) NULL AFTER created_at`);
|
||||
} catch {}
|
||||
|
||||
// Add folder_id column to recipes if missing
|
||||
try {
|
||||
await db.execute(`ALTER TABLE recipes ADD COLUMN folder_id BIGINT NULL AFTER title`);
|
||||
} catch {}
|
||||
try {
|
||||
await db.execute(`ALTER TABLE recipes ADD CONSTRAINT fk_recipes_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
43
src/lib/instaloader.ts
Normal file
43
src/lib/instaloader.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile as _execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
export type DownloadResult = { bytes: Uint8Array; description: string; filePath?: string };
|
||||
|
||||
export async function downloadInstagramVideoBytes(shareUrl: string): Promise<DownloadResult | null> {
|
||||
// Prefer project venv Python if present, fall back to env or system python3
|
||||
const venvPython = join(process.cwd(), 'tools', 'instaloader', '.venv', 'bin', 'python');
|
||||
const pythonBin = existsSync(venvPython) ? venvPython : process.env.PYTHON_BIN || 'python3';
|
||||
const scriptPath = join(process.cwd(), 'tools', 'instaloader', 'download_ig.py');
|
||||
try {
|
||||
const { stdout } = await execFile(pythonBin, [scriptPath, '--url', shareUrl, '--out', join(process.cwd(), 'tools', 'instaloader', 'downloads')], {
|
||||
env: {
|
||||
...process.env,
|
||||
IG_USERNAME: process.env.IG_USERNAME || '',
|
||||
IG_PASSWORD: process.env.IG_PASSWORD || '',
|
||||
IG_SESSIONFILE: process.env.IG_SESSIONFILE || '',
|
||||
},
|
||||
timeout: 120_000,
|
||||
});
|
||||
const last = stdout.trim().split(/\r?\n/).pop();
|
||||
if (!last) return null;
|
||||
let videoPath = last;
|
||||
let description = '';
|
||||
try {
|
||||
const parsed = JSON.parse(last);
|
||||
videoPath = parsed.video_path || parsed.path || parsed;
|
||||
description = parsed.description || '';
|
||||
} catch {}
|
||||
if (!videoPath) return null;
|
||||
const buf = await readFile(videoPath);
|
||||
return { bytes: new Uint8Array(buf), description, filePath: videoPath };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
189
src/lib/openai.ts
Normal file
189
src/lib/openai.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import OpenAI from 'openai';
|
||||
import { toFile } from 'openai/uploads';
|
||||
|
||||
export const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
|
||||
export type RecipeAnalysis = {
|
||||
ingredients: Array<{ name: string; quantity: string | null; unit: string | null; notes?: string | null }>;
|
||||
prep_steps: string[];
|
||||
cooking_steps: string[];
|
||||
};
|
||||
|
||||
export async function uploadImageToOpenAI(bytes: Uint8Array, filename = 'image.jpg') {
|
||||
const file = await openai.files.create({
|
||||
file: await toFile(bytes, filename, { type: 'image/jpeg' }),
|
||||
purpose: 'vision',
|
||||
});
|
||||
return file;
|
||||
}
|
||||
|
||||
export async function analyzeCookingImages(fileIds: string[]): Promise<RecipeAnalysis> {
|
||||
const system = `You are a culinary expert. Analyze ONLY the attached video.
|
||||
Return STRICT JSON with keys: ingredients, prep_steps, cooking_steps.
|
||||
- ingredients: array of { name, quantity (string|null), unit (string|null), notes (string|null) }
|
||||
- prep_steps: array of strings
|
||||
- cooking_steps: array of strings
|
||||
Do not invent details not visible or clearly inferable. If unknown, use null.`;
|
||||
|
||||
const resp = await openai.responses.create({
|
||||
model: process.env.OPENAI_MODEL || 'gpt-4o',
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [{ type: 'input_text', text: system }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
// Provide multiple thumbnails sampled from the video
|
||||
...fileIds.map((id) => ({ type: 'input_image' as const, image: { file_id: id }, detail: 'high' as const })),
|
||||
{
|
||||
type: 'input_text',
|
||||
text: 'Extract ingredients, prep steps, and cooking steps. Output only JSON. No prose.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
// The SDK exposes a helper for text output
|
||||
const text = (resp as any).output_text || (resp as any).content?.[0]?.text || '';
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// Try to salvage JSON if the model added code fences
|
||||
const m = text.match(/\{[\s\S]*\}/);
|
||||
if (m) {
|
||||
return JSON.parse(m[0]);
|
||||
}
|
||||
throw new Error('Model did not return valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeCookingThumbnails(images: Uint8Array[], description?: string): Promise<RecipeAnalysis> {
|
||||
const system = `You are a culinary expert. Analyze ONLY the attached images (video thumbnails).
|
||||
Return STRICT JSON with keys: ingredients, prep_steps, cooking_steps.
|
||||
- ingredients: array of { name, quantity (string|null), unit (string|null), notes (string|null) }
|
||||
- prep_steps: array of strings
|
||||
- cooking_steps: array of strings
|
||||
Do not invent details not visible or clearly inferable. If unknown, use null.`;
|
||||
|
||||
const contentImages = images.map((bytes) => ({
|
||||
type: 'input_image' as const,
|
||||
image_url: `data:image/jpeg;base64,${Buffer.from(bytes).toString('base64')}`,
|
||||
detail: 'high' as const,
|
||||
}));
|
||||
|
||||
const resp = await openai.responses.create({
|
||||
model: process.env.OPENAI_MODEL || 'gpt-4o',
|
||||
input: [
|
||||
{ role: 'system', content: [{ type: 'input_text', text: system }] },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
...(description && description.trim()
|
||||
? ([{ type: 'input_text' as const, text: `Caption/Description: ${description}` }] as const)
|
||||
: ([] as const)),
|
||||
...contentImages,
|
||||
{ type: 'input_text', text: 'Extract ingredients, prep steps, and cooking steps. Output only JSON. No prose.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
const text = (resp as any).output_text || (resp as any).content?.[0]?.text || '';
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
const m = text.match(/\{[\s\S]*\}/);
|
||||
if (m) return JSON.parse(m[0]);
|
||||
throw new Error('Model did not return valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
export async function transcribeAudioBytes(audioBytes: Uint8Array): Promise<string> {
|
||||
const model = process.env.OPENAI_TRANSCRIBE_MODEL || 'gpt-4o-transcribe';
|
||||
const resp = await (openai as any).audio.transcriptions.create({
|
||||
file: await toFile(audioBytes, 'audio.mp3', { type: 'audio/mpeg' }),
|
||||
model,
|
||||
});
|
||||
return (resp as any).text || '';
|
||||
}
|
||||
|
||||
export async function analyzeFromTranscript(transcript: string): Promise<RecipeAnalysis> {
|
||||
const system = `You are a culinary expert. Analyze ONLY the provided transcript of a cooking video. \nReturn STRICT JSON with keys: ingredients, prep_steps, cooking_steps. \n- ingredients: array of { name, quantity (string|null), unit (string|null), notes (string|null) }\n- prep_steps: array of strings\n- cooking_steps: array of strings\nDo not invent details not present. If unknown, use null.`;
|
||||
|
||||
const resp = await openai.responses.create({
|
||||
model: process.env.OPENAI_MODEL || 'gpt-4o',
|
||||
input: [
|
||||
{ role: 'system', content: [{ type: 'input_text', text: system }] },
|
||||
{ role: 'user', content: [{ type: 'input_text', text: transcript }] },
|
||||
],
|
||||
temperature: 0.2,
|
||||
});
|
||||
const text = (resp as any).output_text || (resp as any).content?.[0]?.text || '';
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
const m = text.match(/\{[\s\S]*\}/);
|
||||
if (m) return JSON.parse(m[0]);
|
||||
throw new Error('Model did not return valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeFromTranscriptAndImages(
|
||||
transcript: string,
|
||||
images: Uint8Array[],
|
||||
description?: string,
|
||||
): Promise<RecipeAnalysis> {
|
||||
const system = `You are a culinary expert. Analyze ONLY the provided transcript and ordered thumbnails from a cooking video.\nReturn STRICT JSON with keys: ingredients, prep_steps, cooking_steps.\n- ingredients: array of { name, quantity (string|null), unit (string|null), notes (string|null) }\n- prep_steps: array of strings\n- cooking_steps: array of strings\nIf unknown, use null. Consider the images in order.`;
|
||||
|
||||
const contentImages = images.map((bytes) => ({
|
||||
type: 'input_image' as const,
|
||||
image_url: `data:image/jpeg;base64,${Buffer.from(bytes).toString('base64')}`,
|
||||
detail: 'high' as const,
|
||||
}));
|
||||
|
||||
const resp = await openai.responses.create({
|
||||
model: process.env.OPENAI_MODEL || 'gpt-4o',
|
||||
input: [
|
||||
{ role: 'system', content: [{ type: 'input_text', text: system }] },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
...(description && description.trim()
|
||||
? ([{ type: 'input_text' as const, text: `Caption/Description: ${description}` }] as const)
|
||||
: ([] as const)),
|
||||
...contentImages, // maintain order
|
||||
{ type: 'input_text', text: transcript },
|
||||
{ type: 'input_text', text: 'Extract ingredients, prep steps, and cooking steps. Output only JSON. No prose.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
const text = (resp as any).output_text || (resp as any).content?.[0]?.text || '';
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
const m = text.match(/\{[\s\S]*\}/);
|
||||
if (m) return JSON.parse(m[0]);
|
||||
throw new Error('Model did not return valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateRecipeTitle(input: { description?: string; transcript?: string; analysis?: RecipeAnalysis }): Promise<string> {
|
||||
const prompt = `Given the following context from a cooking video, generate a concise, appealing recipe title (3-7 words). No emojis. No quotes. Return only the title text.\n\nDescription: ${input.description || ''}\n\nTranscript: ${input.transcript || ''}\n\nIngredients: ${(input.analysis?.ingredients || []).map(i => i.name).join(', ')}`;
|
||||
const resp = await openai.responses.create({
|
||||
model: process.env.OPENAI_MODEL || 'gpt-4o-mini',
|
||||
input: [{ role: 'user', content: [{ type: 'input_text', text: prompt }] }],
|
||||
temperature: 0.4,
|
||||
});
|
||||
const text = (resp as any).output_text || (resp as any).content?.[0]?.text || '';
|
||||
return text.trim().replace(/^"|"$/g, '').slice(0, 120);
|
||||
}
|
||||
|
||||
|
||||
43
src/lib/pyktok.ts
Normal file
43
src/lib/pyktok.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile as _execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
export type DownloadTTResult = { bytes: Uint8Array; description: string; filePath?: string };
|
||||
|
||||
export async function downloadTikTokVideoBytes(shareUrl: string): Promise<DownloadTTResult | null> {
|
||||
const venvPython = join(process.cwd(), 'tools', 'pyktok', '.venv', 'bin', 'python');
|
||||
const pythonBin = existsSync(venvPython) ? venvPython : process.env.PYTHON_BIN || 'python3';
|
||||
const scriptPath = join(process.cwd(), 'tools', 'pyktok', 'download_tt.py');
|
||||
try {
|
||||
const { stdout } = await execFile(
|
||||
pythonBin,
|
||||
[scriptPath, '--url', shareUrl, '--out', join(process.cwd(), 'tools', 'pyktok', 'downloads')],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
},
|
||||
timeout: 180_000,
|
||||
},
|
||||
);
|
||||
const last = stdout.trim().split(/\r?\n/).pop();
|
||||
if (!last) return null;
|
||||
let videoPath = last;
|
||||
let description = '';
|
||||
try {
|
||||
const parsed = JSON.parse(last);
|
||||
videoPath = parsed.video_path || parsed.path || parsed;
|
||||
description = parsed.description || '';
|
||||
} catch {}
|
||||
if (!videoPath) return null;
|
||||
const buf = await readFile(videoPath);
|
||||
return { bytes: new Uint8Array(buf), description, filePath: videoPath };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
337
src/lib/resolvers.ts
Normal file
337
src/lib/resolvers.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
Utilities to resolve TikTok/Instagram share links into a direct downloadable video URL.
|
||||
Tries, in order:
|
||||
1) Follow redirects
|
||||
2) Parse HTML meta tags (og:video) or JSON-LD contentUrl
|
||||
3) Use optional RapidAPI endpoints if configured in env
|
||||
*/
|
||||
|
||||
export type ResolveResult = {
|
||||
sourcePlatform: 'tiktok' | 'instagram' | 'unknown';
|
||||
directUrl: string | null;
|
||||
debug?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const DEFAULT_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36';
|
||||
|
||||
function detectPlatform(inputUrl: string): ResolveResult['sourcePlatform'] {
|
||||
const u = inputUrl.toLowerCase();
|
||||
if (u.includes('tiktok.com')) return 'tiktok';
|
||||
if (u.includes('instagram.com') || u.includes('instagr.am')) return 'instagram';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'user-agent': DEFAULT_UA,
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInstagramHtml(url: string): Promise<string | null> {
|
||||
try {
|
||||
const cookieParts: string[] = [];
|
||||
if (process.env.INSTAGRAM_SESSIONID) cookieParts.push(`sessionid=${process.env.INSTAGRAM_SESSIONID}`);
|
||||
if (process.env.INSTAGRAM_CSRFTOKEN) cookieParts.push(`csrftoken=${process.env.INSTAGRAM_CSRFTOKEN}`);
|
||||
const cookie = cookieParts.join('; ');
|
||||
const res = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'user-agent': DEFAULT_UA,
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'x-ig-app-id': '936619743392459',
|
||||
'referer': 'https://www.instagram.com/',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInstagramJson(shortcode: string): Promise<any | null> {
|
||||
const url = `https://www.instagram.com/p/${shortcode}/?__a=1&__d=dis`;
|
||||
try {
|
||||
const cookieParts: string[] = [];
|
||||
if (process.env.INSTAGRAM_SESSIONID) cookieParts.push(`sessionid=${process.env.INSTAGRAM_SESSIONID}`);
|
||||
if (process.env.INSTAGRAM_CSRFTOKEN) cookieParts.push(`csrftoken=${process.env.INSTAGRAM_CSRFTOKEN}`);
|
||||
const cookie = cookieParts.join('; ');
|
||||
const res = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'user-agent': DEFAULT_UA,
|
||||
'accept': 'application/json,text/*;q=0.8',
|
||||
'x-ig-app-id': '936619743392459',
|
||||
'referer': 'https://www.instagram.com/',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const txt = await res.text();
|
||||
try {
|
||||
return JSON.parse(txt);
|
||||
} catch {
|
||||
// Sometimes returns HTML on auth wall
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractOgVideo(html: string): string | null {
|
||||
// Try <meta property="og:video" content="...">
|
||||
const ogMatch = html.match(/<meta[^>]+property=["']og:video["'][^>]+content=["']([^"']+)["'][^>]*>/i);
|
||||
if (ogMatch && ogMatch[1]) return ogMatch[1];
|
||||
|
||||
// Try JSON-LD contentUrl
|
||||
const ldBlocks = html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi) || [];
|
||||
for (const block of ldBlocks) {
|
||||
const contentUrlMatch = block.match(new RegExp('"contentUrl"\\s*:\\s*"(https?:\\\/\\\/[^"\\\\]+)"', 'i'));
|
||||
if (contentUrlMatch && contentUrlMatch[1]) {
|
||||
return contentUrlMatch[1].replace(/\\\//g, '/');
|
||||
}
|
||||
}
|
||||
|
||||
// Some pages include a direct mp4 in window.__INIT_PROPS__ or similar
|
||||
const mp4Match = html.match(/https?:\/\/[^"'\s]+\.mp4/);
|
||||
if (mp4Match) return mp4Match[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function unescapeJsonUrl(s: string): string {
|
||||
try {
|
||||
return s.replace(/\\\//g, '/');
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function findFirstMp4InObject(obj: unknown): string | null {
|
||||
const seen = new Set<unknown>();
|
||||
const stack: unknown[] = [obj];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop();
|
||||
if (!cur || typeof cur !== 'object' || seen.has(cur)) continue;
|
||||
seen.add(cur);
|
||||
for (const [k, v] of Object.entries(cur as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') {
|
||||
const val = unescapeJsonUrl(v);
|
||||
if (/^https?:\/\//i.test(val) && /\.mp4(\?|$)/i.test(val)) return val;
|
||||
// Prefer known TikTok fields
|
||||
if (/playaddr|downloadaddr|nowm/i.test(k) && /^https?:\/\//i.test(val)) return val;
|
||||
} else if (v && typeof v === 'object') {
|
||||
stack.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractJsonByScriptId(html: string, id: string): any | null {
|
||||
const byId = html.match(new RegExp(`<script[^>]+id=["']${id}["'][^>]*>([\\s\\S]*?)<\\/script>`, 'i'));
|
||||
if (byId && byId[1]) {
|
||||
try {
|
||||
return JSON.parse(byId[1]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractJsonByWindowAssign(html: string, name: string): any | null {
|
||||
// Find a <script> block that contains the marker name, then pull the first JSON object inside
|
||||
const block = html.match(new RegExp(`<script[^>]*>[\\s\\S]*?${name}[\\s\\S]*?<\\/script>`, 'i'));
|
||||
if (block && block[0]) {
|
||||
const jsonMatch = block[0].match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTikTokFromHtml(html: string): string | null {
|
||||
// 1) Try SIGI_STATE
|
||||
const sigi = extractJsonByScriptId(html, 'SIGI_STATE') || extractJsonByWindowAssign(html, 'SIGI_STATE');
|
||||
if (sigi) {
|
||||
// Common location: ItemModule -> <videoId> -> video.playAddr / downloadAddr
|
||||
const candidate = findFirstMp4InObject(sigi);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
// 2) Try __NEXT_DATA__
|
||||
const nextData = extractJsonByScriptId(html, '__NEXT_DATA__') || extractJsonByWindowAssign(html, '__NEXT_DATA__');
|
||||
if (nextData) {
|
||||
const candidate = findFirstMp4InObject(nextData);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
// 3) Fallback: any .mp4 URL in HTML
|
||||
const mp4Match = html.match(/https?:\/\/[^"'\s]+\.mp4[^"'\s]*/i);
|
||||
if (mp4Match) return unescapeJsonUrl(mp4Match[0]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractInstagramFromHtml(html: string): string | null {
|
||||
// 1) Any explicit video_url JSON field
|
||||
const videoUrlMatch = html.match(new RegExp('"video_url"\\s*:\\s*"(https:\\/[^\"]+?\\.mp4[^\"]*)"', 'i'));
|
||||
if (videoUrlMatch && videoUrlMatch[1]) return unescapeJsonUrl(videoUrlMatch[1]);
|
||||
// 2) playable_url
|
||||
const playableUrlMatch = html.match(new RegExp('"playable_url"\\s*:\\s*"(https:\\/[^\"]+?\\.mp4[^\"]*)"', 'i'));
|
||||
if (playableUrlMatch && playableUrlMatch[1]) return unescapeJsonUrl(playableUrlMatch[1]);
|
||||
// 3) Any .mp4 in JSON blocks
|
||||
const jsonBlocks = html.match(/<script[^>]*>([\s\S]*?)<\/script>/gi) || [];
|
||||
for (const block of jsonBlocks) {
|
||||
const mp4 = block.match(/https?:\\\/\\\/[^"'\s]+?\.mp4[^"'\s]*/i);
|
||||
if (mp4 && mp4[0]) return unescapeJsonUrl(mp4[0]);
|
||||
}
|
||||
// 4) Fallback handled by extractOgVideo
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractShortcodeFromInstagramUrl(shareUrl: string): string | null {
|
||||
const m = shareUrl.match(/instagram\.com\/(?:p|reel)\/([^\/?#]+)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
async function tryRapidApi(platform: ResolveResult['sourcePlatform'], shareUrl: string): Promise<string | null> {
|
||||
const rapidApiKey = process.env.RAPIDAPI_KEY;
|
||||
if (!rapidApiKey) return null;
|
||||
|
||||
try {
|
||||
if (platform === 'tiktok') {
|
||||
const apiUrl = process.env.RAPIDAPI_TIKTOK_API_URL; // e.g. https://tiktok-video-no-watermark2.p.rapidapi.com/
|
||||
const paramName = process.env.RAPIDAPI_TIKTOK_URL_PARAM || 'url';
|
||||
if (!apiUrl) return null;
|
||||
const url = new URL(apiUrl);
|
||||
url.searchParams.set(paramName, shareUrl);
|
||||
const resp = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'X-RapidAPI-Key': rapidApiKey,
|
||||
'X-RapidAPI-Host': url.host,
|
||||
},
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
// Heuristic extraction of a direct video URL from common RapidAPI responses
|
||||
const candidates: string[] = [];
|
||||
const pushIf = (v?: string) => v && candidates.push(v);
|
||||
pushIf(data?.data?.play); // some APIs
|
||||
pushIf(data?.data?.wmplay);
|
||||
pushIf(data?.data?.nowm); // nowatermark URL
|
||||
pushIf(data?.video); // generic
|
||||
pushIf(data?.result?.nowatermark);
|
||||
pushIf(data?.result?.video);
|
||||
const firstMp4 = candidates.find((c) => typeof c === 'string' && c.includes('.mp4')) || candidates[0];
|
||||
return firstMp4 || null;
|
||||
}
|
||||
|
||||
if (platform === 'instagram') {
|
||||
const apiUrl = process.env.RAPIDAPI_INSTAGRAM_API_URL; // e.g. https://instagram-downloader-download-video.p.rapidapi.com/
|
||||
const paramName = process.env.RAPIDAPI_INSTAGRAM_URL_PARAM || 'url';
|
||||
if (!apiUrl) return null;
|
||||
const url = new URL(apiUrl);
|
||||
url.searchParams.set(paramName, shareUrl);
|
||||
const resp = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'X-RapidAPI-Key': rapidApiKey,
|
||||
'X-RapidAPI-Host': url.host,
|
||||
},
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
const candidates: string[] = [];
|
||||
const pushIf = (v?: string) => v && candidates.push(v);
|
||||
pushIf(data?.video);
|
||||
pushIf(data?.result?.video);
|
||||
pushIf(data?.data?.video);
|
||||
pushIf(data?.media);
|
||||
const firstMp4 = candidates.find((c) => typeof c === 'string' && c.includes('.mp4')) || candidates[0];
|
||||
return firstMp4 || null;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveDirectVideoUrl(shareUrl: string): Promise<ResolveResult> {
|
||||
const platform = detectPlatform(shareUrl);
|
||||
|
||||
// 1) Attempt to follow redirects to already-direct mp4
|
||||
try {
|
||||
const res = await fetch(shareUrl, {
|
||||
redirect: 'follow',
|
||||
headers: { 'user-agent': DEFAULT_UA },
|
||||
});
|
||||
if (res.ok) {
|
||||
const finalUrl = res.url;
|
||||
if (finalUrl && /\.mp4(\?|$)/i.test(finalUrl)) {
|
||||
return { sourcePlatform: platform, directUrl: finalUrl };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
|
||||
// 2) Parse HTML and try platform-specific extraction
|
||||
const html = await fetchText(shareUrl);
|
||||
if (html) {
|
||||
// Platform-first scrapers
|
||||
if (platform === 'tiktok') {
|
||||
const tt = extractTikTokFromHtml(html) || extractOgVideo(html);
|
||||
if (tt) return { sourcePlatform: platform, directUrl: tt };
|
||||
}
|
||||
if (platform === 'instagram') {
|
||||
// Try JSON endpoint with optional session cookie
|
||||
const code = extractShortcodeFromInstagramUrl(shareUrl);
|
||||
if (code) {
|
||||
const json = await fetchInstagramJson(code);
|
||||
if (json) {
|
||||
const mp4FromJson = findFirstMp4InObject(json);
|
||||
if (mp4FromJson) return { sourcePlatform: platform, directUrl: mp4FromJson };
|
||||
}
|
||||
}
|
||||
|
||||
// Try HTML again but with optional session cookie
|
||||
const igHtml = await fetchInstagramHtml(shareUrl);
|
||||
if (igHtml) {
|
||||
const ig = extractInstagramFromHtml(igHtml) || extractOgVideo(igHtml);
|
||||
if (ig) return { sourcePlatform: platform, directUrl: ig };
|
||||
} else {
|
||||
const ig = extractInstagramFromHtml(html) || extractOgVideo(html);
|
||||
if (ig) return { sourcePlatform: platform, directUrl: ig };
|
||||
}
|
||||
}
|
||||
// As a generic fallback, og:video
|
||||
const generic = extractOgVideo(html);
|
||||
if (generic) return { sourcePlatform: platform, directUrl: generic };
|
||||
}
|
||||
|
||||
// 3) Try RapidAPI fallback if configured
|
||||
const rapid = await tryRapidApi(platform, shareUrl);
|
||||
if (rapid) {
|
||||
return { sourcePlatform: platform, directUrl: rapid };
|
||||
}
|
||||
|
||||
return { sourcePlatform: platform, directUrl: null };
|
||||
}
|
||||
|
||||
|
||||
53
src/lib/video.ts
Normal file
53
src/lib/video.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { mkdtemp, writeFile, readFile, readdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile as _execFile } from 'node:child_process';
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
export async function extractThumbnailsFromVideoBytes(
|
||||
videoBytes: Uint8Array,
|
||||
maxFrames: number | null = 8,
|
||||
fps = 2,
|
||||
): Promise<Uint8Array[]> {
|
||||
const tmpBase = await mkdtemp(join(tmpdir(), 'recipe-ai-'));
|
||||
const inputPath = join(tmpBase, 'input.mp4');
|
||||
await writeFile(inputPath, videoBytes);
|
||||
|
||||
const pattern = join(tmpBase, 'frame-%02d.jpg');
|
||||
try {
|
||||
// Extract frames at specified fps; if maxFrames <= 0 or null, extract full video
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-vf', `fps=${fps}`,
|
||||
'-q:v', '2',
|
||||
];
|
||||
if (maxFrames && maxFrames > 0) {
|
||||
args.push('-vframes', String(maxFrames));
|
||||
}
|
||||
args.push(pattern);
|
||||
await execFile('ffmpeg', args, { timeout: 120_000 });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const files = (await readdir(tmpBase))
|
||||
.filter((f) => /^frame-\d+\.jpg$/i.test(f))
|
||||
.sort();
|
||||
const results: Uint8Array[] = [];
|
||||
for (const f of files) {
|
||||
const buf = await readFile(join(tmpBase, f));
|
||||
results.push(new Uint8Array(buf));
|
||||
}
|
||||
return results;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
tools/instaloader/README.md
Normal file
37
tools/instaloader/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Instaloader Helper
|
||||
|
||||
A small wrapper around Instaloader to download a single Instagram post/reel.
|
||||
|
||||
Reference project: instaloader/instaloader (MIT) — https://github.com/instaloader/instaloader
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd tools/instaloader
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Login options
|
||||
- Env vars: `IG_USERNAME`, `IG_PASSWORD`
|
||||
- Or a session file: `IG_SESSIONFILE` together with `--username`. Create it via:
|
||||
```bash
|
||||
instaloader --login YOUR_USERNAME
|
||||
# After successful login, pass the created session file path via IG_SESSIONFILE or --sessionfile
|
||||
```
|
||||
|
||||
## Usage
|
||||
```bash
|
||||
# In the virtualenv
|
||||
python download_ig.py --url https://www.instagram.com/p/DPujDRJDNov/ --out ./downloads
|
||||
# or provide username/password via env
|
||||
IG_USERNAME=... IG_PASSWORD=... python download_ig.py --url https://www.instagram.com/p/.../ --out ./downloads
|
||||
# or use a session file
|
||||
IG_SESSIONFILE=/path/to/sessionfile python download_ig.py --url https://www.instagram.com/reel/.../ --username YOUR_USERNAME
|
||||
```
|
||||
|
||||
- Prints the first downloaded `.mp4` path on success.
|
||||
- Exits with non-zero code if the post is image-only or access is restricted.
|
||||
|
||||
|
||||
137
tools/instaloader/download_ig.py
Normal file
137
tools/instaloader/download_ig.py
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import instaloader
|
||||
except Exception as e:
|
||||
print(f"Error: instaloader not installed. Run: pip install -r requirements.txt\n{e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def extract_shortcode_from_url(url: str):
|
||||
m = re.search(r"instagram\.com\/(?:p|reel)\/([^\/?#]+)", url, re.I)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Download Instagram post media using Instaloader")
|
||||
parser.add_argument('--url', required=True, help='Instagram post/reel URL')
|
||||
parser.add_argument('--out', default='downloads', help='Output directory (default: downloads)')
|
||||
parser.add_argument('--username', default=os.getenv('IG_USERNAME'), help='Instagram username (env IG_USERNAME)')
|
||||
parser.add_argument('--password', default=os.getenv('IG_PASSWORD'), help='Instagram password (env IG_PASSWORD)')
|
||||
parser.add_argument('--sessionfile', default=os.getenv('IG_SESSIONFILE'), help='Instaloader session file path (env IG_SESSIONFILE)')
|
||||
args = parser.parse_args()
|
||||
|
||||
shortcode = extract_shortcode_from_url(args.url)
|
||||
if not shortcode:
|
||||
print('Error: Could not parse shortcode from URL', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
out_dir = Path(args.out).resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
L = instaloader.Instaloader(
|
||||
download_comments=False,
|
||||
post_metadata_txt_pattern='',
|
||||
download_video_thumbnails=False,
|
||||
save_metadata=False,
|
||||
compress_json=False,
|
||||
quiet=True,
|
||||
)
|
||||
# Silence logging further to keep stdout clean for the final path only
|
||||
try:
|
||||
L.context.log = lambda *_a, **_k: None # type: ignore[attr-defined]
|
||||
L.context.log_progress = lambda *_a, **_k: None # type: ignore[attr-defined]
|
||||
L.context.error = lambda *_a, **_k: None # type: ignore[attr-defined]
|
||||
L.context.warning = lambda *_a, **_k: None # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Login if possible
|
||||
if args.sessionfile and args.username:
|
||||
try:
|
||||
L.load_session_from_file(args.username, args.sessionfile)
|
||||
except Exception as e:
|
||||
print(f"Warning: failed to load session from {args.sessionfile}: {e}", file=sys.stderr)
|
||||
elif args.username and args.password:
|
||||
try:
|
||||
L.login(args.username, args.password)
|
||||
except Exception as e:
|
||||
print(f"Warning: login failed: {e}", file=sys.stderr)
|
||||
|
||||
# Resolve and download
|
||||
try:
|
||||
post = instaloader.Post.from_shortcode(L.context, shortcode)
|
||||
except Exception as e:
|
||||
print(f"Error: resolve failed: {e}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
# Prefer downloading just the first video directly into out_dir as <shortcode>.mp4
|
||||
video_url = None
|
||||
try:
|
||||
if getattr(post, 'is_video', False) and getattr(post, 'video_url', None):
|
||||
video_url = post.video_url
|
||||
else:
|
||||
# Sidecar posts may contain one or more videos
|
||||
for node in post.get_sidecar_nodes():
|
||||
if getattr(node, 'is_video', False) and getattr(node, 'video_url', None):
|
||||
video_url = node.video_url
|
||||
break
|
||||
except Exception:
|
||||
video_url = None
|
||||
|
||||
dest_path = (out_dir / f"{shortcode}.mp4").resolve()
|
||||
description = ''
|
||||
try:
|
||||
description = getattr(post, 'caption', '') or ''
|
||||
except Exception:
|
||||
description = ''
|
||||
|
||||
if video_url:
|
||||
try:
|
||||
with requests.get(video_url, stream=True, headers={
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123 Safari/537.36',
|
||||
'accept': '*/*',
|
||||
'referer': 'https://www.instagram.com/',
|
||||
}, timeout=60) as r:
|
||||
r.raise_for_status()
|
||||
with open(dest_path, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=1024 * 256):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
print(json.dumps({"video_path": dest_path.as_posix(), "description": description}, ensure_ascii=False))
|
||||
return
|
||||
except Exception:
|
||||
# Fall back to full download if direct fetch fails
|
||||
pass
|
||||
|
||||
# Fallback: Use Instaloader's downloader into a temp subdir, then move the first mp4 up
|
||||
try:
|
||||
target = out_dir / shortcode
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
L.download_post(post, target.as_posix())
|
||||
mp4s = sorted(target.glob('*.mp4'))
|
||||
if not mp4s:
|
||||
print('No video found (post may be image-only)', file=sys.stderr)
|
||||
sys.exit(4)
|
||||
src_path = mp4s[0]
|
||||
if dest_path.exists():
|
||||
dest_path.unlink()
|
||||
shutil.move(src_path.as_posix(), dest_path.as_posix())
|
||||
print(json.dumps({"video_path": dest_path.as_posix(), "description": description}, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
print(f"Error: download failed: {e}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
||||
BIN
tools/instaloader/downloads/DLNigR4JYM7.mp4
Normal file
BIN
tools/instaloader/downloads/DLNigR4JYM7.mp4
Normal file
Binary file not shown.
3
tools/instaloader/requirements.txt
Normal file
3
tools/instaloader/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
instaloader==4.14.2
|
||||
|
||||
|
||||
30
tools/pyktok/README.md
Normal file
30
tools/pyktok/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Pyktok Helper
|
||||
|
||||
A small wrapper around Pyktok to download a single TikTok video.
|
||||
|
||||
Reference: Pyktok on PyPI — https://pypi.org/project/pyktok/
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd tools/pyktok
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
# Install browser drivers required by playwright
|
||||
python -m playwright install
|
||||
# On some systems you may also need OS deps:
|
||||
# python -m playwright install-deps
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# In the virtualenv
|
||||
python download_tt.py --url "https://www.tiktok.com/@user/video/123..." --out ./downloads
|
||||
# Prints the absolute path to the downloaded .mp4 on success
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Pyktok may require a logged-in browser session for certain videos. See Pyktok docs for `specify_browser` usage if needed.
|
||||
- This script emits only the final .mp4 path on stdout for easy consumption by Node.
|
||||
75
tools/pyktok/download_tt.py
Normal file
75
tools/pyktok/download_tt.py
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pyktok as pyk # type: ignore
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error: pyktok not installed. Run: pip install -r requirements.txt\n{e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Download TikTok video using Pyktok")
|
||||
parser.add_argument('--url', required=True, help='TikTok share URL')
|
||||
parser.add_argument('--out', default='downloads', help='Output directory (default: downloads)')
|
||||
args = parser.parse_args()
|
||||
|
||||
out_dir = Path(args.out).resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Change CWD to output dir so pyktok saves files here
|
||||
os.chdir(out_dir.as_posix())
|
||||
|
||||
# Save video and a metadata CSV in the output directory
|
||||
meta_csv = out_dir / 'tiktok_metadata.csv'
|
||||
try:
|
||||
pyk.save_tiktok(args.url, True, meta_csv.as_posix())
|
||||
except Exception as e:
|
||||
print(f"Error: download failed: {e}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
# Find the most recent mp4 in the output dir
|
||||
mp4s = sorted(out_dir.glob('*.mp4'), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if not mp4s:
|
||||
print('No video found', file=sys.stderr)
|
||||
sys.exit(4)
|
||||
|
||||
# Attempt to retrieve description via JSON
|
||||
description = ''
|
||||
try:
|
||||
data = pyk.alt_get_tiktok_json(args.url)
|
||||
# recursive search for 'desc' or 'description'
|
||||
def find_desc(obj):
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if isinstance(k, str) and k.lower() in ('desc', 'description', 'title') and isinstance(v, str):
|
||||
return v
|
||||
found = find_desc(v)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(obj, list):
|
||||
for it in obj:
|
||||
found = find_desc(it)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
d = find_desc(data)
|
||||
if isinstance(d, str):
|
||||
description = d
|
||||
except Exception:
|
||||
description = ''
|
||||
|
||||
print(json.dumps({"video_path": mp4s[0].as_posix(), "description": description}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
||||
5
tools/pyktok/requirements.txt
Normal file
5
tools/pyktok/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
pyktok==0.0.31
|
||||
playwright>=1.46.0
|
||||
requests>=2.31.0
|
||||
|
||||
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user