main
Some checks failed
Deploy Backend / deploy (push) Has been cancelled

This commit is contained in:
2026-02-05 01:29:22 +03:00
parent ae24c17f50
commit 80dcf4d04a
30 changed files with 14275 additions and 0 deletions

535
services/archiveService.ts Normal file
View File

@@ -0,0 +1,535 @@
import archiver from 'archiver';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import { PrismaClient } from '@prisma/client';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const prisma = new PrismaClient();
const STORAGE_ROOT = path.resolve(__dirname, '../../storage');
// Aspect Ratio Map for exact print dimensions
const ASPECT_RATIO_MAP: Record<string, [number, number]> = {
"1:1": [6000, 6000],
"3:4": [4500, 6000],
"4:3": [6000, 4500],
"4:5": [4800, 6000],
"5:4": [6000, 4800],
"9:16": [3375, 6000],
"16:9": [6000, 3375],
"2:3": [4000, 6000],
"3:2": [6000, 4000],
"1:4": [1500, 6000],
"4:1": [6000, 1500],
};
const MIN_PRINT_SIZE = 6000;
export class ArchiveService {
// 1. MASTER ZIP: Everything (Source of Truth)
async createMasterArchive(projectId: string, res: any) {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { assets: true, seoData: true, user: { include: { etsyShops: true } } }
});
if (!project) throw new Error("Project not found");
const archive = archiver('zip', { zlib: { level: 9 } });
res.attachment(`MasterBundle_${project.productType}_${projectId.substring(0, 6)}.zip`);
archive.pipe(res);
const qualityLog: string[] = [];
qualityLog.push(`QUALITY VALIDATION REPORT`);
qualityLog.push(`Generated: ${new Date().toISOString()}`);
qualityLog.push(`Project ID: ${projectId}`);
qualityLog.push(`Product Type: ${project.productType}`);
qualityLog.push(`----------------------------------------`);
// A. Add All Assets & Validate (with Print-Ready Enforcement)
const MIN_PRINT_SIZE = 6000; // Minimum long side for print-ready
for (const asset of project.assets) {
const assetPath = path.join(STORAGE_ROOT, asset.path);
if (fs.existsSync(assetPath)) {
const internalPath = `assets/${asset.type}/${path.basename(assetPath)}`;
// VALIDATION & ENFORCEMENT (Only for images)
if (assetPath.endsWith('.png') || assetPath.endsWith('.jpg')) {
try {
const meta = await sharp(assetPath).metadata();
const currentWidth = meta.width || 0;
const currentHeight = meta.height || 0;
const longSide = Math.max(currentWidth, currentHeight);
const isPrintReady = longSide >= MIN_PRINT_SIZE && (meta.density || 72) >= 300;
const colorSpace = meta.channels === 4 ? "RGB+Alpha" : meta.channels === 3 ? "RGB" : "CMYK/Other";
// For MASTER and UPSCALED: Enforce 6000px @ 300 DPI
if (['master', 'upscaled'].includes(asset.type) && !isPrintReady) {
const ratio = currentWidth / currentHeight;
let finalWidth: number;
let finalHeight: number;
if (currentWidth >= currentHeight) {
finalWidth = MIN_PRINT_SIZE;
finalHeight = Math.round(MIN_PRINT_SIZE / ratio);
} else {
finalHeight = MIN_PRINT_SIZE;
finalWidth = Math.round(MIN_PRINT_SIZE * ratio);
}
// Upscale on-the-fly for archive
const upscaledBuffer = await sharp(assetPath)
.resize(finalWidth, finalHeight, { kernel: sharp.kernel.lanczos3, fit: 'fill' })
.withMetadata({ density: 300 })
.png({ quality: 100, compressionLevel: 6 })
.toBuffer();
archive.append(upscaledBuffer, { name: `assets/${asset.type}/PrintReady_${finalWidth}x${finalHeight}_${path.basename(assetPath)}` });
qualityLog.push(`[UPSCALED FOR PRINT] ${internalPath}`);
qualityLog.push(` - Original: ${currentWidth}x${currentHeight}px | Density: ${meta.density || 72}dpi`);
qualityLog.push(` - Upscaled to: ${finalWidth}x${finalHeight}px @ 300 DPI for print-readiness.`);
} else {
// Add as-is (already compliant or non-master asset)
archive.file(assetPath, { name: internalPath });
const status = isPrintReady ? "PRINT-READY ✓" : (longSide >= 2000 ? "PASS" : "WARN (Low Res)");
qualityLog.push(`[${status}] ${internalPath}`);
qualityLog.push(` - Dims: ${currentWidth}x${currentHeight}px | Density: ${meta.density || 72}dpi | Color: ${colorSpace}`);
if (!isPrintReady && ['master', 'upscaled'].includes(asset.type)) {
qualityLog.push(` - WARNING: Asset may be too small for large format printing.`);
}
}
} catch (e) {
qualityLog.push(`[ERROR] validation failed for ${internalPath}: ${(e as Error).message}`);
// Still add the original file on error
archive.file(assetPath, { name: internalPath });
}
} else {
// Non-image assets (text files, etc.)
archive.file(assetPath, { name: internalPath });
}
} else {
qualityLog.push(`[MISSING] Asset record found but file missing: ${asset.path}`);
}
}
// B. Add Restoration Manifest (Machine Readable)
const manifest = {
version: "1.0",
timestamp: new Date().toISOString(),
project: {
id: project.id,
niche: project.niche,
productType: project.productType,
creativity: project.creativity,
aspectRatio: project.aspectRatio
},
seo: project.seoData,
assets: project.assets.map(a => ({ type: a.type, path: a.path, meta: a.meta }))
};
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
// C. Add Quality Report
archive.append(qualityLog.join('\n'), { name: 'docs/QUALITY_REPORT.txt' });
// D. Add Strategy / SEO
if (project.seoData) {
// Construct the State Object exactly how the UI components demand it
// CRITICAL FIX: Fallback to seoData if strategy is missing (Recovered Projects Scenario)
const resolvedStrategy = (project.seoData as any) || {};
const shopName = project.user?.etsyShops?.[0]?.shopName || "The Artist";
// Format Attributes
let attributesStr = "None";
try {
if ((project.seoData as any).attributes) {
const attrs = JSON.parse((project.seoData as any).attributes);
attributesStr = Object.entries(attrs).map(([k, v]) => `- ${k.toUpperCase()}: ${v}`).join('\n');
}
} catch (e) { }
const seoContent = `
TITLE: ${(project as any).sku ? `[${(project as any).sku}] ` : ''}${project.seoData.title}
SKU: ${(project as any).sku || 'Not Assigned'}
CATEGORY PATH: ${(project.seoData as any).categoryPath || "Not set"}
DESCRIPTION:
${project.seoData.description}
KEYWORDS: ${JSON.parse(project.seoData.keywords as string || '[]').join(', ')}
HIDDEN ATTRIBUTES:
${attributesStr}
--------------------------------------
Generated for ${shopName} via DigiCraft
`;
archive.append(seoContent, { name: 'docs/SEO_METADATA.txt' });
if (project.seoData.printingGuide) {
const guidePath = path.join(STORAGE_ROOT, project.seoData.printingGuide);
if (fs.existsSync(guidePath)) {
archive.file(guidePath, { name: 'docs/Printing_Guide.txt' });
}
}
}
await archive.finalize();
}
// 2. CUSTOMER ARCHIVE: Filtering sensitive info, only deliverables
async createCustomerArchive(projectId: string, mode: 'rgb' | 'cmyk', res: any) {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { assets: true, seoData: true, user: { include: { etsyShops: true } } }
});
if (!project) throw new Error("Project not found");
const shopName = (project.user as any)?.etsyShopName || project.user?.etsyShops?.[0]?.shopName || "The Artist";
const shopUrl = (project.user as any)?.etsyShopLink || "";
const archive = archiver('zip', { zlib: { level: 9 } });
res.attachment(`CustomerDownload_${mode.toUpperCase()}_${projectId.substring(0, 6)}.zip`);
archive.pipe(res);
// ----------------------------------------------------
// A. Neuro-Marketing Experience Pack (Etsy-Compliant)
// ----------------------------------------------------
const readmeContent = `
═══════════════════════════════════════════════════════════════════════════════
✨ THANK YOU FOR CHOOSING ${shopName.toUpperCase()}! ✨
═══════════════════════════════════════════════════════════════════════════════
Congratulations on your wonderful choice! 🎨
We're so excited that you've chosen to bring this artwork into your space.
Every pixel has been crafted with care, and we can't wait to see how you use it.
───────────────────────────────────────────────────────────────────────────────
📥 WHAT'S INSIDE THIS PACKAGE?
───────────────────────────────────────────────────────────────────────────────
${mode === 'cmyk' ? `✓ Professional CMYK Print Files (.tif format)
→ Optimized for commercial printing
→ 300 DPI high-resolution
→ Ready for offset, laser, or inkjet printing` : `✓ Digital RGB Files (.png format)
→ Perfect for digital displays
→ 300 DPI high-resolution
→ Vibrant colors for screens and home printers`}
✓ Multiple Aspect Ratios Included:
→ 3:4 (9x12, 18x24 inches)
→ 4:5 (8x10, 16x20 inches)
→ 2:3 (4x6, 24x36 inches)
→ 5:7 / ISO (A1-A5 international)
→ 11:14 (Exclusive frame size)
───────────────────────────────────────────────────────────────────────────────
🖨️ HOW TO PRINT (3 Easy Steps)
───────────────────────────────────────────────────────────────────────────────
1⃣ Choose your file based on your frame size
2⃣ Upload to a print service (Costco, Walgreens, local print shop)
3⃣ Select "Original Size" or "Fit to Frame" and print!
💡 Pro Tip: For the BEST quality, use a professional print service
with heavyweight matte or luster paper (200gsm+).
───────────────────────────────────────────────────────────────────────────────
💬 WE'D LOVE TO HEAR FROM YOU
───────────────────────────────────────────────────────────────────────────────
Your feedback helps us grow and helps other buyers make informed decisions.
If you have a moment, sharing your experience would mean the world to us.
Whether it's a quick thought or a photo of the art in your space —
we genuinely appreciate every message. 💌
───────────────────────────────────────────────────────────────────────────────
❓ NEED HELP?
───────────────────────────────────────────────────────────────────────────────
Having trouble? We're here for you!
Simply send us a message through Etsy and we'll respond within 24 hours.
With gratitude,
The ${shopName} Team 🌟
${shopUrl ? `\n🔗 Visit our shop: ${shopUrl}` : ''}
═══════════════════════════════════════════════════════════════════════════════
`;
archive.append(readmeContent.trim(), { name: '00_READ_ME_FIRST.txt' });
const licenseContent = `
═══════════════════════════════════════════════════════════════════════════════
📜 PERSONAL USE LICENSE AGREEMENT
═══════════════════════════════════════════════════════════════════════════════
Thank you for supporting independent artists! 🙏
This license grants you the following rights:
───────────────────────────────────────────────────────────────────────────────
✅ WHAT YOU CAN DO
───────────────────────────────────────────────────────────────────────────────
• Print and display this artwork in your home or office
• Use as a digital wallpaper on your personal devices
• Gift a PRINTED copy to friends or family
• Print multiple copies for your own personal spaces
───────────────────────────────────────────────────────────────────────────────
❌ WHAT'S NOT ALLOWED
───────────────────────────────────────────────────────────────────────────────
• Reselling, sharing, or redistributing the digital files
• Uploading to print-on-demand sites (Redbubble, Amazon, Etsy, etc.)
• Using for commercial products without a commercial license
• Claiming this artwork as your own creation
• Modifying and reselling as a "new" product
───────────────────────────────────────────────────────────────────────────────
🛡️ COPYRIGHT NOTICE
───────────────────────────────────────────────────────────────────────────────
This artwork is protected by copyright law.
Unauthorized distribution or commercial use is prohibited.
We put our heart into every design. Thank you for respecting
the rights of independent artists and small businesses. ❤️
© ${new Date().getFullYear()} ${shopName}. All Rights Reserved.
───────────────────────────────────────────────────────────────────────────────
📧 QUESTIONS ABOUT LICENSING?
───────────────────────────────────────────────────────────────────────────────
Need a commercial license? Want to use this for a business project?
Just send us a message — we're happy to discuss custom licensing options!
═══════════════════════════════════════════════════════════════════════════════
`;
archive.append(licenseContent.trim(), { name: '01_LICENSE.txt' });
// NEW: Thank You Card with Soft Review Request
const thankYouContent = `
═══════════════════════════════════════════════════════════════════════════════
💌 A PERSONAL NOTE FROM THE ARTIST
═══════════════════════════════════════════════════════════════════════════════
Hey there, art lover! 👋
I wanted to take a moment to personally thank you for your purchase.
As a small business owner, every order feels like a little celebration.
Your support allows me to keep creating, keep dreaming, and keep
bringing beautiful art into the world. That means everything.
───────────────────────────────────────────────────────────────────────────────
🌟 SHARE YOUR EXPERIENCE
───────────────────────────────────────────────────────────────────────────────
If your order arrived safely and you're happy with the quality,
I'd be incredibly grateful if you could take a moment to share
your experience with other buyers.
Your honest feedback — whatever it may be — helps others discover
our work and helps us understand how we can serve you better.
(No pressure, no expectations. Just genuine appreciation for
any thoughts you're willing to share. 🙏)
───────────────────────────────────────────────────────────────────────────────
📸 WE LOVE SEEING YOUR SPACE!
───────────────────────────────────────────────────────────────────────────────
Hung up your print? Styled it on your desk?
We'd love to see how you've decorated your space!
Tag us or send a photo — we might feature you on our page!
───────────────────────────────────────────────────────────────────────────────
Until next time,
${shopName} 🎨✨
${shopUrl ? `\n🔗 ${shopUrl}` : ''}
═══════════════════════════════════════════════════════════════════════════════
`;
archive.append(thankYouContent.trim(), { name: '02_THANK_YOU.txt' });
// ----------------------------------------------------
// B. Add Deliverables (with Print-Ready Enforcement)
// ----------------------------------------------------
// Filter: Only Master and Variants
const deliverables = project.assets.filter(a => ['master', 'variant', 'upscaled'].includes(a.type));
// Get target dimensions from project's aspect ratio
const targetRatio = project.aspectRatio || "3:4";
let targetWidth: number;
let targetHeight: number;
if (ASPECT_RATIO_MAP[targetRatio]) {
[targetWidth, targetHeight] = ASPECT_RATIO_MAP[targetRatio];
} else {
// Fallback for unknown ratios
const [w, h] = targetRatio.split(':').map(Number);
if (w > h) {
targetWidth = MIN_PRINT_SIZE;
targetHeight = Math.round(MIN_PRINT_SIZE * h / w);
} else {
targetHeight = MIN_PRINT_SIZE;
targetWidth = Math.round(MIN_PRINT_SIZE * w / h);
}
}
const nameCount: Record<string, number> = {};
for (const asset of deliverables) {
const assetPath = path.join(STORAGE_ROOT, asset.path);
if (fs.existsSync(assetPath)) {
// filename logic: SEO Title (truncated) + Ratio
let safeTitle = "Artwork";
if (project.seoData?.title) {
safeTitle = project.seoData.title
.replace(/[^a-zA-Z0-9\s-_]/g, '') // Remove special chars
.trim()
.replace(/\s+/g, '_'); // Replace spaces with underscores
if (safeTitle.length > 40) {
safeTitle = safeTitle.substring(0, 40);
}
}
try {
const meta = await sharp(assetPath).metadata();
const currentWidth = meta.width || 0;
const currentHeight = meta.height || 0;
// Check if image is already at target specifications
const isPrintReady = (currentWidth === targetWidth && currentHeight === targetHeight);
// Determine Ratio String
let ratioStr = "Original";
// Simple GCD or lookup could be better, but approximation works for filenames
const r = currentWidth / currentHeight;
if (Math.abs(r - 3 / 4) < 0.05) ratioStr = "3x4";
else if (Math.abs(r - 4 / 5) < 0.05) ratioStr = "4x5";
else if (Math.abs(r - 2 / 3) < 0.05) ratioStr = "2x3";
else if (Math.abs(r - 9 / 16) < 0.05) ratioStr = "9x16";
else if (Math.abs(r - 5 / 7) < 0.05) ratioStr = "5x7";
else if (Math.abs(r - 11 / 14) < 0.05) ratioStr = "11x14";
else if (Math.abs(r - 1) < 0.05) ratioStr = "1x1";
else ratioStr = `${currentWidth}x${currentHeight}`;
// Dynamic Truncation Logic to preserve Suffix/SKU
const skuPrefix = (project as any).sku ? `${(project as any).sku}_` : "";
const ratioSuffix = `_${ratioStr}`;
const targetExt = mode === 'cmyk' ? '.tif' : '.png';
const MAX_FILENAME_LEN = 65; // Strict limit
const reservedLen = skuPrefix.length + ratioSuffix.length + targetExt.length;
const availableTitleLen = MAX_FILENAME_LEN - reservedLen;
let finalTitle = safeTitle;
if (finalTitle.length > availableTitleLen) {
finalTitle = finalTitle.substring(0, Math.max(10, availableTitleLen)); // Ensure at least 10 chars
}
// Construct Final Name
// Handle duplicates by pre-checking against nameCount map?
// Actually, simple counter logic needs to happen BEFORE full truncation or AFTER?
// Better to rely on unique ratioStr for master assets.
// But if multiple assets map to same ratio (e.g. variants), we need index.
let baseNameCandidate = `${skuPrefix}${finalTitle}${ratioSuffix}`;
if (nameCount[baseNameCandidate]) {
nameCount[baseNameCandidate]++;
// Adjust title slightly to accommodate counter?
// Or just append counter and risk going over limit slightly?
// Let's shorten title more if needed.
const countStr = `_${nameCount[baseNameCandidate]}`;
if ((baseNameCandidate.length + countStr.length + targetExt.length) > 70) {
// Shrink title more
const reduceBy = (baseNameCandidate.length + countStr.length + targetExt.length) - 70;
finalTitle = finalTitle.substring(0, finalTitle.length - reduceBy);
baseNameCandidate = `${skuPrefix}${finalTitle}${ratioSuffix}`;
}
baseNameCandidate += countStr;
} else {
nameCount[baseNameCandidate] = 1;
}
const finalFilename = `${baseNameCandidate}${targetExt}`;
// ... Processing buffers ...
let sourceBuffer: Buffer;
// Upscale and enforce exact ratio for master/upscaled types
if (!isPrintReady && ['master', 'upscaled'].includes(asset.type)) {
sourceBuffer = await sharp(assetPath)
.resize(targetWidth, targetHeight, {
kernel: sharp.kernel.lanczos3,
fit: 'cover',
position: 'center'
})
.withMetadata({ density: 300 })
.png({ quality: 100, compressionLevel: 6 })
.toBuffer();
} else {
sourceBuffer = fs.readFileSync(assetPath);
}
if (mode === 'cmyk') {
// TRUE CMYK Conversion
let cmykBuffer: Buffer;
try {
cmykBuffer = await sharp(sourceBuffer)
.pipelineColourspace('rgb16')
.toColourspace('cmyk')
.withMetadata({ density: 300 })
.tiff({ compression: 'lzw', quality: 100, xres: 300, yres: 300 })
.toBuffer();
} catch (cmykError: any) {
console.warn(`[CMYK] Fallback for ${finalFilename}`);
cmykBuffer = await sharp(sourceBuffer)
.withMetadata({ density: 300 })
.tiff({ compression: 'lzw', quality: 100, xres: 300, yres: 300 })
.toBuffer();
}
archive.append(cmykBuffer, { name: `print_ready/${finalFilename}` });
} else {
archive.append(sourceBuffer, { name: `digital_files_rgb/${finalFilename}` });
}
} catch (e) {
console.error(`Processing failed for ${assetPath}:`, e);
// Fallback to original
archive.file(assetPath, { name: `fallback/${path.basename(assetPath)}` });
}
}
}
// Add Guide
if (project.seoData?.printingGuide) {
const guidePath = path.join(STORAGE_ROOT, project.seoData.printingGuide);
if (fs.existsSync(guidePath)) {
archive.file(guidePath, { name: 'Printing_Instructions.txt' });
}
}
await archive.finalize();
}
}
export const archiveService = new ArchiveService();

175
services/etsyAuth.ts Normal file
View File

@@ -0,0 +1,175 @@
import crypto from 'crypto';
import axios from 'axios';
import { PrismaClient } from '@prisma/client';
import FormData from 'form-data';
const prisma = new PrismaClient();
const ETSY_KEY_STRING = process.env.ETSY_KEY_STRING || '';
// Shared secret is usually not needed for v3 PKCE flows unless specific scopes/endpoints require signing,
// but we keep it in env just in case.
const ETSY_REDIRECT_URI = 'http://localhost:3001/api/etsy/callback';
export class EtsyAuthService {
// Generate PKCE Challenge
static generateChallenge() {
const codeVerifier = crypto.randomBytes(32).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const state = crypto.randomBytes(16).toString('hex');
return { codeVerifier, codeChallenge, state };
}
// Get Auth URL
static getAuthUrl(codeChallenge: string, state: string, scopes: string[]) {
const params = new URLSearchParams({
response_type: 'code',
client_id: ETSY_KEY_STRING,
redirect_uri: ETSY_REDIRECT_URI,
scope: scopes.join(' '),
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `https://www.etsy.com/oauth/connect?${params.toString()}`;
}
// Exchange Code for Token
static async getAccessToken(code: string, codeVerifier: string) {
try {
const response = await axios.post('https://api.etsy.com/v3/public/oauth/token', {
grant_type: 'authorization_code',
client_id: ETSY_KEY_STRING,
redirect_uri: ETSY_REDIRECT_URI,
code: code,
code_verifier: codeVerifier,
});
return response.data; // { access_token, refresh_token, expires_in, etc }
} catch (error: any) {
console.error('Etsy Token Exchange Error:', error.response?.data || error.message);
throw new Error('Failed to exchange code for token');
}
}
// Refresh Token
static async refreshToken(refreshToken: string) {
try {
const response = await axios.post('https://api.etsy.com/v3/public/oauth/token', {
grant_type: 'refresh_token',
client_id: ETSY_KEY_STRING,
refresh_token: refreshToken,
});
return response.data;
} catch (error: any) {
console.error('Etsy Token Refresh Error:', error.response?.data || error.message);
throw new Error('Failed to refresh token');
}
}
// Get User/Shop ID
static async getSelf(accessToken: string) {
try {
const response = await axios.get('https://api.etsy.com/v3/application/users/me', {
headers: {
'x-api-key': ETSY_KEY_STRING,
'Authorization': `Bearer ${accessToken}`
}
});
// Response usually contains user_id. From there we can get the shop.
return response.data;
} catch (error: any) {
console.error('getSelf Error:', error.response?.data || error.message);
throw error;
}
}
static async getShop(userId: string | number, accessToken: string) {
try {
const response = await axios.get(`https://api.etsy.com/v3/application/users/${userId}/shops`, {
headers: {
'x-api-key': ETSY_KEY_STRING,
'Authorization': `Bearer ${accessToken}`
}
});
return response.data; // Returns list of shops (usually 1)
} catch (error: any) {
console.error('getShop Error:', error.response?.data || error.message);
throw error;
}
}
// --- LISTING MANAGEMENT (V3) ---
static async createDraftListing(shopId: string, accessToken: string, data: {
title: string,
description: string,
price: number,
quantity: number,
who_made: 'i_did' | 'collective' | 'someone_else',
when_made: 'made_to_order' | '2020_2025' | '2010_2019' | string,
is_supply: boolean,
taxonomy_id: number
}) {
try {
const response = await axios.post(`https://api.etsy.com/v3/application/shops/${shopId}/listings`, data, {
headers: {
'x-api-key': ETSY_KEY_STRING,
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
return response.data; // { listing_id, ... }
} catch (error: any) {
console.error('createDraftListing Error:', error.response?.data || error.message);
throw error;
}
}
static async uploadListingImage(shopId: string, listingId: number | string, accessToken: string, imageBuffer: Buffer, filename: string) {
try {
const formData = new FormData();
formData.append('image', imageBuffer, { filename });
const response = await axios.post(`https://api.etsy.com/v3/application/shops/${shopId}/listings/${listingId}/images`, formData, {
headers: {
'x-api-key': ETSY_KEY_STRING,
'Authorization': `Bearer ${accessToken}`,
...formData.getHeaders()
}
});
return response.data;
} catch (error: any) {
console.error('uploadListingImage Error:', error.response?.data || error.message);
throw error;
}
}
// Helper to check and refresh token if needed
static async ensureValidToken(shopRecord: any) {
const now = BigInt(Math.floor(Date.now() / 1000));
if (shopRecord.expiresAt > now + BigInt(60)) {
return shopRecord.accessToken;
}
console.log(`[Etsy] Refreshing token for shop ${shopRecord.shopName}`);
const tokenData = await this.refreshToken(shopRecord.refreshToken);
const updated = await prisma.etsyShop.update({
where: { id: shopRecord.id },
data: {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt: BigInt(Math.floor(Date.now() / 1000) + tokenData.expires_in)
}
});
return updated.accessToken;
}
}

395
services/geminiService.ts Normal file
View File

@@ -0,0 +1,395 @@
import { GoogleGenAI } from "@google/genai";
import dotenv from "dotenv";
dotenv.config();
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY as string });
interface StickerPlan {
prompts: string[];
characterCore: string;
}
export const geminiService = {
/**
* The Brain: Analyzes the user's prompt and generates a list of distinct scenarios.
* @param originalPrompt The user's raw input
* @param count Number of variations to generate
* @returns List of prompts and the core character description
*/
async generateStickerSetPlan(originalPrompt: string, count: number): Promise<StickerPlan> {
console.log(`[GeminiService] Planning ${count} sticker variations...`);
const planningPrompt = `
You are an expert Sticker Set Planner.
Analyze this sticker prompt: "${originalPrompt}"
1. Extract the CORE VISUAL IDENTITY (Character description, style, colors, clothes).
This must be preserved EXACTLY in every variation.
2. Generate ${count} DISTINCT variation prompts.
Each variation must be a different pose, emotion, or action suitable for a sticker pack.
(e.g., Happy, Sad, Thinking, Coffee, Running, Sleeping, Winking, etc.)
Output JSON ONLY:
{
"characterCore": "The extracted core visual description...",
"variations": [
"Full prompt for variation 1...",
"Full prompt for variation 2..."
]
}
Rules for Variations:
- Combine the "Character Core" with the new pose/action.
- Ensure the output is a FULL stable diffusion style prompt (tags).
- Keep the style tags consistent.
`;
try {
const response = await ai.models.generateContent({
model: "gemini-2.0-flash",
contents: [{ text: planningPrompt }],
config: {
responseMimeType: "application/json"
} as any
});
const text = response.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error("No response from Gemini");
const json = JSON.parse(text);
let prompts = json.variations || [];
// SECURITY: Force array to be string[]
if (!Array.isArray(prompts)) {
prompts = [originalPrompt];
}
// CRITICAL FIX: Ensure we have exactly 'count' items
// If AI returns fewer, we pad with the existing ones (round-robin)
if (prompts.length < count) {
console.warn(`[GeminiService] AI returned only ${prompts.length}/${count} variations. Padding...`);
while (prompts.length < count) {
// pushing a random existing prompt to fill the gap
prompts.push(prompts[prompts.length % prompts.length]);
}
}
// If AI determines more are needed, trim? No, extra is fine, but let's slice just in case UI expects N
// Actually, extra is bonus. But user paid for N? We usually don't charge per variant in this model, but batch cost.
return {
prompts: prompts.slice(0, count), // Ensure exact count
characterCore: json.characterCore || originalPrompt
};
} catch (error: any) {
console.error("Gemini Planning Error:", error);
// Fallback: Just repeat the prompt if planning fails? Or error out?
// For now, return the original prompt repeated to avoid crash, but log error.
return {
prompts: Array(count).fill(originalPrompt),
characterCore: originalPrompt
};
}
},
/**
* X-Ray: Updates the user on the visual DNA, strategic gaps, and superior prompt.
*/
async analyzeCompetitorProduct(params: {
title: string;
description: string;
imageBase64: string;
apiKey?: string;
}): Promise<any> {
console.log(`[GeminiService] Running Competitor X-Ray Analysis...`);
const { title, description, imageBase64, apiKey } = params;
// Strip header if present to avoid 500 errors
const cleanBase64 = imageBase64.replace(/^data:image\/\w+;base64,/, "");
// Use the specialized Vision model for analysis
const MODEL_NAME = "gemini-2.0-flash";
const analysisPrompt = `
You are an elite E-commerce Strategist and Art Director.
Analyze this competitor product to help us create something SUPERIOR.
PRODUCT CONTEXT:
Title: "${title}"
Description: "${description.substring(0, 500)}..."
TASK:
1. VISUAL DNA: Deconstruct the aesthetic formula (Color palette info, composition style, emotional triggers).
2. SENTIMENT GAP: Identify what is missing or could be improved (e.g., "Lighting is too flat", "Composition is cluttered").
3. SUPERIOR PROMPT: Write a "Nano Banana" style stable diffusion prompt to generate a version of this product that is 10x BETTER.
- Must use (weighted:1.2) tags.
- Must include quality boosters.
- Must solve the identified gaps.
OUTPUT JSON ONLY:
{
"visualDna": ["Tag 1", "Tag 2", "Hex Colors", "Composition Rule"],
"sentimentGap": "Brief strategic analysis of weaknesses...",
"superiorPrompt": "(masterpiece:1.4), ...",
"gapAnalysis": "Detailed explanation of why the new prompt is better"
}
`;
try {
const client = apiKey ? new GoogleGenAI({ apiKey }) : ai;
const response = await client.models.generateContent({
model: MODEL_NAME,
contents: [
{
role: "user",
parts: [
{ text: analysisPrompt },
{
inlineData: {
mimeType: "image/jpeg",
data: cleanBase64
}
}
]
}
],
config: {
responseMimeType: "application/json"
} as any
});
const text = response.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error("No response from AI");
return JSON.parse(text);
} catch (error: any) {
console.error("Gemini X-Ray Error:", error);
throw new Error("Failed to analyze product: " + error.message);
}
},
/**
* Neuro-Scorecard: Analyzes an image for commercial potential using neuro-marketing principles.
*/
async analyzeImageNeuroScore(params: {
imageBase64: string;
apiKey?: string;
}): Promise<any> {
console.log(`[GeminiService] Running Neuro-Scorecard Analysis...`);
const { imageBase64, apiKey } = params;
// Strip header if present
const cleanBase64 = imageBase64.replace(/^data:image\/\w+;base64,/, "");
// Use Vision model for image analysis
const MODEL_NAME = "gemini-2.0-flash";
const analysisPrompt = `
You are a Neuromarketing Expert and Senior Art Director.
Score this image based on its potential to sell on Etsy.
ANALYZE THESE DIMENSIONS (Score 0-10):
1. **Dopamine Hit**: Does it create immediate excitement/craving?
2. **Serotonin Flow**: Does it evoke trust, calm, or belonging?
3. **Cognitive Ease**: Is it easy to process instantly? (High score = distinct subject, clear lighting).
4. **Commercial Fit**: Does it look like a high-end product vs. an amateur photo?
CRITICAL INSTRUCTION FOR "IMPROVEMENTS":
- YOU MUST PROVIDE EXACTLY 2 SPECIFIC IMPROVEMENTS PER CATEGORY.
- EVEN IF THE SCORE IS 10/10, suggest experimental tweaks.
- Do NOT give generic advice like "increase contrast".
- BE SPECIFIC to the image content. Mention specific objects, colors, or areas.
⚠️ FORBIDDEN SUGGESTIONS (NEVER SUGGEST THESE):
- Mockups (e.g., "show this in a living room", "place on a wall")
- Context/environment changes (e.g., "add a frame", "show as wall art")
- Marketing/presentation ideas (e.g., "include in a bundle", "show lifestyle shot")
✅ ALLOWED SUGGESTIONS (ONLY THESE TYPES):
- Color adjustments (saturation, hue, warmth, vibrancy)
- Lighting changes (exposure, shadows, highlights, contrast)
- Composition tweaks (crop, reframe, balance, focal point)
- Detail enhancements (sharpness, texture, remove artifacts)
- Style refinements (artistic filters, mood adjustments, grain)
- Example GOOD: "Increase the saturation of the red vase to make it pop."
- Example GOOD: "Add subtle vignette to draw eye to center."
- Example BAD: "Put this in a living room mockup." ❌
- NEVER RETURN AN EMPTY ARRAY.
OUTPUT JSON ONLY:
{
"scores": {
"dopamine": 8.5,
"serotonin": 7.0,
"cognitiveEase": 9.0,
"commercialFit": 6.5
},
"feedback": [
"Positive point 1",
"Positive point 2"
],
"improvements": {
"dopamine": ["Specific fix 1", "Specific fix 2"],
"serotonin": ["Specific fix 1", "Specific fix 2"],
"cognitiveEase": ["Specific fix 1", "Specific fix 2"],
"commercialFit": ["Specific fix 1", "Specific fix 2"]
},
"prediction": "High/Medium/Low Conversion Potential"
}
`;
try {
const client = apiKey ? new GoogleGenAI({ apiKey }) : ai;
const response = await client.models.generateContent({
model: MODEL_NAME,
contents: [
{
role: "user",
parts: [
{ text: analysisPrompt },
{
inlineData: {
mimeType: "image/jpeg",
data: cleanBase64
}
}
]
}
],
config: {
responseMimeType: "application/json"
} as any
});
const text = response.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error("No response from AI");
const data = JSON.parse(text);
// NORMALIZE DATA: Check if AI returned old format or missing keys
if (!data.improvements) {
console.warn("[GeminiService] AI returned old format, normalizing...");
data.improvements = {
dopamine: [],
serotonin: [],
cognitiveEase: [],
commercialFit: data.criticalImprovements || [] // Fallback to old field
};
}
// Ensure all keys exist
const defaults = { dopamine: [], serotonin: [], cognitiveEase: [], commercialFit: [] };
data.improvements = { ...defaults, ...data.improvements };
return data;
} catch (error: any) {
console.error("Gemini Neuro-Score Error:", error);
throw new Error("Failed to score image: " + error.message);
}
},
/**
* Web Research: Uses Google Search Grounding to extract metadata from a URL.
* Bypasses local IP blocking by using Google's servers.
*/
async performWebResearch(url: string, apiKey?: string): Promise<{ title: string, description: string, image: string }> {
console.log(`[GeminiService] Performing Google Search Grounding for: ${url}`);
const researchPrompt = `
Analyze this product URL: "${url}"
TASK:
Extract the following metadata from this product page:
1. Product Title (Exact full title)
2. Product Description (First 2-3 sentences summary)
3. Main Product Image URL (Direct link to the highest resolution image, must be a full URL starting with https://)
IMPORTANT: You MUST return valid JSON. Do not include any text before or after the JSON.
OUTPUT FORMAT (JSON ONLY):
{
"title": "Product Title Here",
"description": "Product Description Here...",
"image": "https://full-url-to-image.jpg"
}
`;
try {
const client = apiKey ? new GoogleGenAI({ apiKey }) : ai;
// First try with Google Search grounding
let response;
try {
response = await client.models.generateContent({
model: "gemini-2.0-flash",
contents: [{ text: researchPrompt }],
tools: [{
googleSearch: {}
}],
config: {
responseMimeType: "application/json"
}
} as any);
} catch (searchError: any) {
console.warn("[GeminiService] Google Search grounding failed, trying without:", searchError.message);
// Fallback: Try without search grounding
response = await client.models.generateContent({
model: "gemini-2.0-flash",
contents: [{ text: researchPrompt }],
config: {
responseMimeType: "application/json"
} as any
});
}
const text = response.candidates?.[0]?.content?.parts?.[0]?.text;
console.log("[GeminiService] Raw research response:", text?.substring(0, 200));
if (!text) throw new Error("No response from AI Research");
// Try to parse JSON, handle cases where response might have extra text
let data;
try {
data = JSON.parse(text);
} catch (parseError) {
// Try to extract JSON from text
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (jsonMatch) {
data = JSON.parse(jsonMatch[0]);
} else {
throw new Error("Could not parse JSON from response");
}
}
// Validate required fields
if (!data.title) {
console.warn("[GeminiService] Missing title in response, extracting from URL...");
// Extract title from URL as fallback
const urlParts = url.split('/');
const slug = urlParts.find(p => p.length > 10 && !p.includes('.'));
data.title = slug ? slug.replace(/-/g, ' ') : "Unknown Product";
}
if (!data.image || !data.image.startsWith('http')) {
console.warn("[GeminiService] Invalid or missing image URL");
data.image = "";
}
console.log(`[GeminiService] Research Success: ${data.title}`);
return data;
} catch (error: any) {
console.error("Gemini Research Error:", error);
throw new Error("Failed to research url: " + error.message);
}
}
};

View File

@@ -0,0 +1,192 @@
import sharp from 'sharp';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
// Paper Dimensions (at 300 DPI)
const PAPER_SIZES: Record<string, { width: number, height: number }> = {
'A4': { width: 2480, height: 3508 }, // 210mm x 297mm
'Letter': { width: 2550, height: 3300 }, // 8.5in x 11in
'A5': { width: 1748, height: 2480 } // 148mm x 210mm
};
export const stickerSheetService = {
/**
* Add a white border and optional cut line to an image.
* @param inputPath Path to the source image (sticker).
* @param borderWidth Width of the white contour in pixels (default 40).
*/
async addCutContour(inputPath: string, borderWidth: number = 40): Promise<Buffer> {
// 1. Load Image
const image = sharp(inputPath);
const metadata = await image.metadata();
if (!metadata.width || !metadata.height) throw new Error("Invalid image metadata");
// 2. Create Mask (Alpha Channel)
const alpha = await image.clone()
.toColourspace('b-w')
.ensureAlpha()
.extractChannel(3) // Get alpha channel
.raw()
.toBuffer();
// 3. Dilate Mask to create Border Shape
// Proper dilation requires morphological operations which Sharp doesn't support natively well for complex shapes without external libs.
// TRICK: We will blur the alpha channel and threshold it to simulate dilation/expansion.
const expandedMask = await sharp(alpha, { raw: { width: metadata.width, height: metadata.height, channels: 1 } })
.blur(borderWidth) // Spread the alpha
.threshold(10) // Binarize to create hard edge
.toBuffer();
// 4. Create White Silhouette using the expanded mask
const whiteBackground = await sharp({
create: {
width: metadata.width,
height: metadata.height,
channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 1 }
}
})
.joinChannel(expandedMask) // Apply mask
.png()
.toBuffer();
// 5. Composite Original Image ON TOP of White Silhouette
return sharp(whiteBackground)
.composite([{ input: inputPath }])
.withMetadata({ density: 300 }) // Enforce 300 DPI
.png()
.toBuffer();
},
/**
* Generate a BLACKOUT mask for Cricut Cut Lines.
* Returns a binary verification image (Black shape on transparent background).
*/
async generateCutMask(inputPath: string, borderWidth: number = 40): Promise<Buffer> {
// 1. Load Image
const image = sharp(inputPath);
const metadata = await image.metadata();
if (!metadata.width || !metadata.height) throw new Error("Invalid image metadata");
// 2. Extract Alpha
const alpha = await image.clone()
.toColourspace('b-w')
.ensureAlpha()
.extractChannel(3)
.raw()
.toBuffer();
// 3. Dilate (Same logic as addCutContour to match perfectly)
const expandedMask = await sharp(alpha, { raw: { width: metadata.width, height: metadata.height, channels: 1 } })
.blur(borderWidth)
.threshold(10)
.toBuffer();
// 4. Create BLACK Silhouette
return sharp({
create: {
width: metadata.width,
height: metadata.height,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 } // BLACK
}
})
.joinChannel(expandedMask) // Apply same mask
.withMetadata({ density: 300 }) // Enforce 300 DPI
.png()
.toBuffer();
},
/**
* Arrange multiple stickers onto a single sheet.
* @param stickerPaths Array of absolute paths to sticker images.
* @param options Configuration for the sheet (target dimensions, paper size name)
*/
async generateSheet(stickerPaths: string[], options: { width: number, height: number, paperSizeName?: string }): Promise<Buffer> {
const { width, height } = options;
// Target 4-5 columns for A4/Letter at 2500px width.
// If width is ~5000px (High Res A4), we still want ~4-5 columns?
// Or do we want more stickers if we have them?
// Usually a sticker sheet has a fixed number of stickers.
// Let's assume we want a standard grid.
// Let's base sticker size on WIDTH.
// 4 cols + 5 gaps = width.
// stickerW * 4 + gap * 5 = width.
// let gap = stickerW / 10.
// stickerW * 4 + stickerW/2 = width => 4.5 stickerW = width.
const COLS = 4;
// Calculation:
// Width = COLS * StickerW + (COLS + 1) * GAP
// Let GAP = StickerW * 0.1
// Width = 4 * S + 5 * 0.1 * S = 4.5 S
const stickerWidth = Math.floor(width / (COLS + (COLS + 1) * 0.1));
const gap = Math.floor(stickerWidth * 0.1);
console.log(`[StickerService] Generating Sheet ${width}x${height}. Sticker W: ${stickerWidth}, Gap: ${gap}`);
const composites: sharp.OverlayOptions[] = [];
let currentX = gap;
let currentY = gap;
let maxHeightInRow = 0;
for (const stickerPath of stickerPaths) {
let stickerBuffer;
try {
stickerBuffer = await this.addCutContour(stickerPath, Math.floor(stickerWidth * 0.05)); // Scale border too
} catch (e) {
console.error(`Failed to process sticker ${stickerPath}`, e);
continue;
}
// Resize to target width
const resizedSticker = await sharp(stickerBuffer)
.resize({ width: stickerWidth })
.toBuffer();
const meta = await sharp(resizedSticker).metadata();
const w = meta.width || stickerWidth;
const h = meta.height || stickerWidth;
// Check if fits in current row
if (currentX + w + gap > width) {
// Next Row
currentX = gap;
currentY += maxHeightInRow + gap;
maxHeightInRow = 0;
}
// Check if fits in page (vertical)
if (currentY + h + gap > height) {
console.warn("Sheet full, skipping remaining stickers");
break;
}
composites.push({
input: resizedSticker,
top: currentY,
left: currentX
});
currentX += w + gap;
if (h > maxHeightInRow) maxHeightInRow = h;
}
// Create Sheet Canvas
return sharp({
create: {
width: width,
height: height,
channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 0 } // Transparent
}
})
.composite(composites)
.png()
.toBuffer();
}
};

180
services/usageService.ts Normal file
View File

@@ -0,0 +1,180 @@
import { PrismaClient } from '@prisma/client';
import { GoogleGenAI } from "@google/genai";
const prisma = new PrismaClient() as any;
// COST CONSTANTS (USD)
const COSTS = {
GENERATE_PROMPT: 0.001, // Gemini Pro Text
GENERATE_MASTER: 0.040, // Imagen 3 (High Res)
GENERATE_VARIANT: 0.020, // Imagen 3 (Standard/Fast)
GENERATE_MOCKUP: 0.020, // Imagen 3 (Standard/Fast)
REFINE_PROJECT: 0.040, // Imagen 3 (High Res) - treat same as Master
MOCKUP_REMOVAL: 0.005 // Background Removal (Hypothetical)
};
// CREDIT PRICES
const PRICES = {
GENERATE_PROMPT: 1,
GENERATE_MASTER: 10,
GENERATE_VARIANT: 5,
GENERATE_MOCKUP: 5,
REFINE_PROJECT: 10, // Same as Master
MOCKUP_REMOVAL: 2
};
export type ActionType = keyof typeof COSTS;
// CACHE: In-memory store for pricing config to reduce DB hits
// Default TTL: 60 seconds
let configCache: { [key: string]: number } | null = null;
let cacheExpiry = 0;
const CACHE_TTL_MS = 60 * 1000;
async function getPricing(action: ActionType): Promise<{ cost: number, credits: number }> {
const now = Date.now();
// 1. Refresh Cache if expired
if (!configCache || now > cacheExpiry) {
try {
const configs = await prisma.systemConfig.findMany();
configCache = {};
// Populate cache
configs.forEach((c: any) => {
const val = parseFloat(c.value);
if (!isNaN(val)) {
configCache![c.key] = val;
}
});
cacheExpiry = now + CACHE_TTL_MS;
// console.log("[UsageService] Pricing cache refreshed.");
} catch (e) {
console.error("[UsageService] Failed to fetch system config, using defaults.", e);
// If DB fails, fallback to empty cache (which triggers defaults below)
if (!configCache) configCache = {};
}
}
// 2. Resolve Values (DB > Default)
// Keys in DB: COST_GENERATE_MASTER, PRICE_GENERATE_MASTER
const costKey = `COST_${action}`;
const priceKey = `PRICE_${action}`;
const cost = (configCache && configCache[costKey] !== undefined)
? configCache[costKey]
: COSTS[action];
const credits = (configCache && configCache[priceKey] !== undefined)
? configCache[priceKey]
: PRICES[action];
return { cost, credits };
}
export const usageService = {
/**
* Deducts credits using DYNAMIC pricing.
*/
async deductCredits(userId: string, action: ActionType) {
const { cost, credits } = await getPricing(action);
return await prisma.$transaction(async (tx: any) => {
const user = await tx.user.findUnique({ where: { id: userId } });
if (!user) throw new Error("User not found");
// ADMIN OVERRIDE: Unlimited Credits
// God Mode: Admins bypass all checks.
if (user.role === 'ADMIN' || user.role === 'VIP') {
console.log(`[UsageService] ⚡ GOD MODE: User is ${user.role}, bypassing credit deduction.`);
return user;
}
if (user.credits < credits) {
// Determine if we should show the 'cost' in error message
throw new Error(`Insufficient credits for ${action}. Needed: ${credits}, Balance: ${user.credits}`);
}
const updatedUser = await tx.user.update({
where: { id: userId },
data: {
credits: { decrement: credits },
totalCost: { increment: cost }
}
});
await tx.usageLog.create({
data: {
userId,
action,
cost,
credits
}
});
return updatedUser;
});
},
/**
* Helper to get current price for UI (without deducting)
*/
async getActionPrice(action: ActionType) {
return await getPricing(action);
},
async recordPurchase(userId: string, amountUSD: number, creditsGiven: number) {
return await prisma.$transaction(async (tx: any) => {
await tx.user.update({
where: { id: userId },
data: {
credits: { increment: creditsGiven },
totalRevenue: { increment: amountUSD }
}
});
await tx.transaction.create({
data: {
userId,
amount: amountUSD,
credits: creditsGiven,
type: "PURCHASE"
}
});
});
},
/**
* Validates a Gemini API Key by making a minimal test call.
*/
async validateApiKey(apiKey: string): Promise<{ valid: boolean, error?: string, code?: string }> {
if (!apiKey || apiKey.length < 20) return { valid: false, error: "API Key is too short or missing.", code: "INVALID_FORMAT" };
try {
const genAI = new GoogleGenAI({ apiKey });
// Minimal token generation to test validity & quota
await genAI.models.generateContent({
model: "gemini-3-flash-preview", // Updated to a confirmed available model
contents: [{ role: "user", parts: [{ text: "Hi" }] }],
config: { maxOutputTokens: 1 }
});
return { valid: true };
} catch (error: any) {
console.warn("[UsageService] API Key Validation Failed:", error.message);
const msg = error.message || "";
if (msg.includes("403") || msg.includes("API key not valid")) {
return { valid: false, error: "Invalid API Key. Please check characters.", code: "INVALID_KEY" };
}
if (msg.includes("429") || msg.includes("quota")) {
return { valid: false, error: "API Key Quota Exceeded. You may need to enable billing.", code: "QUOTA_EXCEEDED" };
}
if (msg.includes("400")) {
return { valid: false, error: "Bad Request. Key might be malformed.", code: "BAD_REQUEST" };
}
return { valid: false, error: "Validator Error: " + msg, code: "UNKNOWN_ERROR" };
}
}
};

96
services/xrayService.ts Normal file
View File

@@ -0,0 +1,96 @@
import axios from 'axios';
import { geminiService } from './geminiService.js';
interface XRayResult {
success: boolean;
data?: {
metadata: {
title: string;
description: string;
image: string;
tags: string[];
};
analysis: {
visualDna: string[];
sentimentGap: string;
superiorPrompt: string;
gapAnalysis: string;
};
};
error?: string;
}
export const xrayService = {
/**
* Scrapes a product URL and performs deep AI analysis
*/
async analyzeProduct(url: string, apiKey?: string): Promise<XRayResult> {
try {
console.log(`[X-Ray] Analyzing: ${url}`);
// 1. WEB RESEARCH (Google Search Grounding)
// Replaces legacy Axios scraping which gets blocked (403/422) by Etsy
const researchResult = await geminiService.performWebResearch(url, apiKey);
if (!researchResult.title) {
throw new Error("AI Retrieval failed to extract product title. The URL may not be accessible or the product page structure is unsupported.");
}
if (!researchResult.image) {
throw new Error("AI Retrieval could not find a product image URL. Please ensure the URL points to a valid product page.");
}
console.log("[X-Ray] Metadata extracted via Google:", researchResult.title);
// 2. Perform AI Analysis (Visual + Text)
// We pass the image URL directly if public, OR we might need to download it first base64.
// For stability, let's download the image to base64 buffer
// Note: Since we have the direct image URL now (likely CDN), downloadImage should work better
// than scraping the main page.
const imageBuffer = await this.downloadImage(researchResult.image);
const imageBase64 = imageBuffer.toString('base64');
const metadata = {
title: researchResult.title,
description: researchResult.description,
image: researchResult.image,
tags: [] // Search might not return tags, optional
};
const analysis = await geminiService.analyzeCompetitorProduct({
title: researchResult.title,
description: researchResult.description,
imageBase64: imageBase64,
apiKey
});
return {
success: true,
data: {
metadata,
analysis
}
};
} catch (error: any) {
console.error("[X-Ray] Error:", error.message);
return {
success: false,
error: error.message || "Failed to analyze product"
};
}
},
async downloadImage(url: string): Promise<Buffer> {
const response = await axios.get(url, {
responseType: 'arraybuffer',
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
'Referer': 'https://www.etsy.com/'
}
});
return Buffer.from(response.data, 'binary');
}
};