face generator
This commit is contained in:
9
.clinerules/pinterest.md
Normal file
9
.clinerules/pinterest.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Pinterest operations
|
||||
Use this file src\lib\pinterest.ts for pinterest operation.
|
||||
You can modify if needed.
|
||||
|
||||
# Get PinId from Keyword
|
||||
Use this method getPinUrlFromPinterest
|
||||
|
||||
# Get image from pinId
|
||||
Use this method downloadImageFromPin
|
||||
@ -9,7 +9,8 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"db:schema": "ts-node src/schema.ts",
|
||||
"db:test": "ts-node src/testmysql.ts",
|
||||
"infinity:start": "ts-node src/infinityvideo_generator/start.ts"
|
||||
"infinity:start": "ts-node src/infinityvideo_generator/start.ts",
|
||||
"convert:pinterest-face": "ts-node src/imageconverter/pinterest_face_portrait.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
194
src/comfyworkflows/edit_image_2_qwen.json
Normal file
194
src/comfyworkflows/edit_image_2_qwen.json
Normal file
@ -0,0 +1,194 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "Load Diffusion Model"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "Load CLIP"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_vae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"lora_name": "Qwen-Image-Lightning-8steps-V2.0.safetensors",
|
||||
"strength_model": 1,
|
||||
"model": [
|
||||
"1",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"_meta": {
|
||||
"title": "LoraLoaderModelOnly"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"11",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"_meta": {
|
||||
"title": "ConditioningZeroOut"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"seed": 135126386717887,
|
||||
"steps": 8,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "beta",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"4",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"11",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"5",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"11",
|
||||
6
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"7",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"11": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"21",
|
||||
0
|
||||
],
|
||||
"enable_resize": true,
|
||||
"enable_vl_resize": true,
|
||||
"upscale_method": "lanczos",
|
||||
"crop": "disabled",
|
||||
"instruction": "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n",
|
||||
"clip": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"image1": [
|
||||
"24",
|
||||
0
|
||||
],
|
||||
"image2": [
|
||||
"15",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImageEditPlus_lrzjason",
|
||||
"_meta": {
|
||||
"title": "TextEncodeQwenImageEditPlus 小志Jason(xiaozhijason)"
|
||||
}
|
||||
},
|
||||
"15": {
|
||||
"inputs": {
|
||||
"image": "ComfyUI_00067_.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "Load Image"
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"inputs": {
|
||||
"filename_prefix": "qwenedit",
|
||||
"images": [
|
||||
"8",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "Save Image"
|
||||
}
|
||||
},
|
||||
"21": {
|
||||
"inputs": {
|
||||
"value": "change the face and hairstyle of image1 to image2"
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "String (Multiline)"
|
||||
}
|
||||
},
|
||||
"24": {
|
||||
"inputs": {
|
||||
"measurement": "pixels",
|
||||
"width": 720,
|
||||
"height": 1280,
|
||||
"fit": "pad",
|
||||
"method": "nearest-exact",
|
||||
"image": [
|
||||
"63",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Image Resize (rgthree)",
|
||||
"_meta": {
|
||||
"title": "Image Resize (rgthree)"
|
||||
}
|
||||
},
|
||||
"63": {
|
||||
"inputs": {
|
||||
"image": "00d3c030856044bb99b804b4ef88f4be.jpg"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "Load Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
193
src/comfyworkflows/edit_image_qwen.json
Normal file
193
src/comfyworkflows/edit_image_qwen.json
Normal file
@ -0,0 +1,193 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "Load Diffusion Model"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "Load CLIP"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_vae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"lora_name": "Qwen-Image-Lightning-8steps-V2.0.safetensors",
|
||||
"strength_model": 1,
|
||||
"model": [
|
||||
"1",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"_meta": {
|
||||
"title": "LoraLoaderModelOnly"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"11",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"_meta": {
|
||||
"title": "ConditioningZeroOut"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"seed": 838097333311955,
|
||||
"steps": 8,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "beta",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"4",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"11",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"5",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"11",
|
||||
6
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"7",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"11": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"21",
|
||||
0
|
||||
],
|
||||
"enable_resize": true,
|
||||
"enable_vl_resize": true,
|
||||
"upscale_method": "lanczos",
|
||||
"crop": "disabled",
|
||||
"instruction": "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n",
|
||||
"clip": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"image1": [
|
||||
"24",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImageEditPlus_lrzjason",
|
||||
"_meta": {
|
||||
"title": "TextEncodeQwenImageEditPlus 小志Jason(xiaozhijason)"
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"inputs": {
|
||||
"filename_prefix": "qwenedit",
|
||||
"images": [
|
||||
"8",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "Save Image"
|
||||
}
|
||||
},
|
||||
"21": {
|
||||
"inputs": {
|
||||
"value": "change camera angle to closeup face from image1, change background to light gray with faing gradient, change face angle to look at directry look at camera"
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "String (Multiline)"
|
||||
}
|
||||
},
|
||||
"24": {
|
||||
"inputs": {
|
||||
"measurement": "pixels",
|
||||
"width": 720,
|
||||
"height": 1280,
|
||||
"fit": "contain",
|
||||
"method": "nearest-exact",
|
||||
"image": [
|
||||
"64",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Image Resize (rgthree)",
|
||||
"_meta": {
|
||||
"title": "Image Resize (rgthree)"
|
||||
}
|
||||
},
|
||||
"64": {
|
||||
"inputs": {
|
||||
"image": "1337074888177434_1758776251440_2.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "Load Image"
|
||||
}
|
||||
},
|
||||
"65": {
|
||||
"inputs": {
|
||||
"images": [
|
||||
"24",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "PreviewImage",
|
||||
"_meta": {
|
||||
"title": "Preview Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/imageconverter/pinterest_face_portrait.ts
Normal file
172
src/imageconverter/pinterest_face_portrait.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import dotenv from 'dotenv';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { logger } from '../lib/logger';
|
||||
import { getPinUrlFromPinterest, downloadImageFromPin } from '../lib/pinterest';
|
||||
import { convertImage } from '../lib/image-converter';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const KEYWORDS = [
|
||||
'teen girl portrait',
|
||||
'woman portrait',
|
||||
'woman face close',
|
||||
'teen face close',
|
||||
'beautiful woman face closeup',
|
||||
'beautiful teen',
|
||||
'russian teen',
|
||||
'skandinavian teen',
|
||||
'uk teen',
|
||||
'asian teen',
|
||||
'east european teen',
|
||||
'cute teen',
|
||||
'beautiful woman',
|
||||
'russian woman',
|
||||
'skandinavian woman',
|
||||
'uk woman',
|
||||
'asian woman',
|
||||
'east european woman',
|
||||
'cute woman',];
|
||||
const TARGET_COUNT = Number(process.env.IMAGE_COUNT || 20);
|
||||
const PROMPT =
|
||||
`change camera angle to closeup face from image1,
|
||||
change background to light gray with faing gradient,
|
||||
change face angle to look at directry look at camera˛
|
||||
change lighting to soft light,
|
||||
change face expression to neautral expression,
|
||||
change age to 20 years old,
|
||||
`;
|
||||
|
||||
type ServerCfg = { baseUrl: string; outputDir: string; inputDir: string };
|
||||
|
||||
function getServerConfig(): ServerCfg {
|
||||
const candidates = [
|
||||
{ baseUrl: process.env.SERVER1_COMFY_BASE_URL, outputDir: process.env.SERVER1_COMFY_OUTPUT_DIR },
|
||||
//{ baseUrl: process.env.SERVER2_COMFY_BASE_URL, outputDir: process.env.SERVER2_COMFY_OUTPUT_DIR },
|
||||
].filter((s): s is { baseUrl: string; outputDir: string } => !!s.baseUrl && !!s.outputDir);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
'No ComfyUI server configured. Please set SERVER1_COMFY_BASE_URL/OUTPUT_DIR or SERVER2_COMFY_BASE_URL/OUTPUT_DIR in .env'
|
||||
);
|
||||
}
|
||||
const chosen = candidates[0];
|
||||
const inputDir = chosen.outputDir.replace('output', 'input');
|
||||
return { ...chosen, inputDir };
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((res) => setTimeout(res, ms));
|
||||
}
|
||||
|
||||
async function ensureDir(p: string) {
|
||||
try {
|
||||
await fs.mkdir(p, { recursive: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function collectImages(keyword: string, total: number): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
// ensure local download dir exists (pinterest.ts also ensures it, but harmless here)
|
||||
await ensureDir(path.join(process.cwd(), 'download'));
|
||||
|
||||
while (results.length < total) {
|
||||
try {
|
||||
const pinUrl = await getPinUrlFromPinterest(keyword);
|
||||
if (!pinUrl) {
|
||||
logger.warn('No pin URL found, retrying...');
|
||||
await sleep(1500);
|
||||
continue;
|
||||
}
|
||||
const remaining = total - results.length;
|
||||
// attempt to grab up to 5 per pin to reduce churn
|
||||
const batchTarget = Math.min(5, remaining);
|
||||
const imgs = await downloadImageFromPin(pinUrl, batchTarget);
|
||||
if (imgs && imgs.length > 0) {
|
||||
results.push(...imgs);
|
||||
logger.info(`Downloaded ${imgs.length} image(s) from ${pinUrl}.Progress: ${results.length}/${total}`);
|
||||
} else {
|
||||
logger.warn(`Pin yielded no downloadable images: ${pinUrl}`);
|
||||
}
|
||||
await sleep(1000 + Math.random() * 1000);
|
||||
} catch (err) {
|
||||
logger.error('Error while collecting images:', err);
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
return results.slice(0, total);
|
||||
}
|
||||
|
||||
async function processImages(imagePaths: string[], server: ServerCfg) {
|
||||
await ensureDir(server.inputDir);
|
||||
|
||||
for (const localImagePath of imagePaths) {
|
||||
const baseName = path.basename(localImagePath);
|
||||
const serverInputPath = path.join(server.inputDir, baseName);
|
||||
|
||||
try {
|
||||
// copy source image into ComfyUI input dir so workflow node can find it by filename
|
||||
await fs.copyFile(localImagePath, serverInputPath);
|
||||
logger.info(`Copied ${localImagePath} -> ${serverInputPath}`);
|
||||
|
||||
// Run conversion (sequential to avoid output race conditions)
|
||||
const generatedPath = await convertImage(
|
||||
PROMPT,
|
||||
baseName,
|
||||
server.baseUrl,
|
||||
server.outputDir,
|
||||
{ width: 720, height: 1280 } // portrait
|
||||
);
|
||||
logger.info(`Generated image: ${generatedPath}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to convert ${localImagePath}:`, err);
|
||||
} finally {
|
||||
// cleanup both server input copy and local downloaded file
|
||||
try {
|
||||
await fs.unlink(serverInputPath);
|
||||
} catch { }
|
||||
try {
|
||||
await fs.unlink(localImagePath);
|
||||
} catch { }
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const server = getServerConfig();
|
||||
|
||||
// Infinite loop as requested
|
||||
while (true) {
|
||||
|
||||
for (const KEYWORD of KEYWORDS) {
|
||||
logger.info(`Starting Pinterest image conversion loop for keyword: "${KEYWORD}" (target ${TARGET_COUNT})`);
|
||||
try {
|
||||
const images = await collectImages(KEYWORD, TARGET_COUNT);
|
||||
logger.info(`Collected ${images.length} image(s). Starting conversion...`);
|
||||
await processImages(images, server);
|
||||
logger.info('Iteration completed. Sleeping before next cycle...');
|
||||
} catch (err) {
|
||||
logger.error('Iteration failed:', err);
|
||||
}
|
||||
|
||||
// brief pause between iterations
|
||||
await sleep(10000);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('UnhandledRejection:', reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.error('UncaughtException:', err);
|
||||
});
|
||||
|
||||
main().catch((e) => {
|
||||
logger.error('Fatal error:', e);
|
||||
});
|
||||
69
src/lib/image-converter.ts
Normal file
69
src/lib/image-converter.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
interface ImageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
async function convertImage(
|
||||
prompt: string,
|
||||
newFileName: string,
|
||||
comfyBaseUrl: string,
|
||||
comfyOutputDir: string,
|
||||
size: ImageSize = { width: 720, height: 1280 }
|
||||
): Promise<string> {
|
||||
const COMFY_BASE_URL = comfyBaseUrl.replace(/\/$/, '');
|
||||
const COMFY_OUTPUT_DIR = comfyOutputDir;
|
||||
let workflow;
|
||||
|
||||
workflow = JSON.parse(await fs.readFile('src/comfyworkflows/edit_image_qwen.json', 'utf-8'));
|
||||
workflow['21']['inputs']['value'] = prompt;
|
||||
workflow['24']['inputs']['width'] = size.width;
|
||||
workflow['24']['inputs']['height'] = size.height;
|
||||
workflow['64']['inputs']['image'] = newFileName;
|
||||
|
||||
const response = await axios.post(`${COMFY_BASE_URL}/prompt`, { prompt: workflow });
|
||||
const promptId = response.data.prompt_id;
|
||||
|
||||
let history;
|
||||
do {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
const historyResponse = await axios.get(`${COMFY_BASE_URL}/history/${promptId}`);
|
||||
history = historyResponse.data[promptId];
|
||||
} while (!history || Object.keys(history.outputs).length === 0);
|
||||
|
||||
const files = await fs.readdir(COMFY_OUTPUT_DIR!);
|
||||
const generatedFiles = files.filter(file => file.startsWith('qwenedit'));
|
||||
|
||||
const fileStats = await Promise.all(
|
||||
generatedFiles.map(async (file) => {
|
||||
const stat = await fs.stat(path.join(COMFY_OUTPUT_DIR!, file));
|
||||
return { file, mtime: stat.mtime };
|
||||
})
|
||||
);
|
||||
|
||||
fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
const latestFile = fileStats[0].file;
|
||||
const newFilePath = path.resolve('./generated', newFileName);
|
||||
|
||||
await fs.mkdir('./generated', { recursive: true });
|
||||
|
||||
const sourcePath = path.join(COMFY_OUTPUT_DIR!, latestFile);
|
||||
try {
|
||||
await fs.unlink(newFilePath);
|
||||
} catch (err) {
|
||||
// ignore if not exists
|
||||
}
|
||||
|
||||
await fs.copyFile(sourcePath, newFilePath);
|
||||
|
||||
return newFilePath;
|
||||
}
|
||||
|
||||
export { convertImage };
|
||||
116
src/lib/pinterest.ts
Normal file
116
src/lib/pinterest.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { logger } from './logger';
|
||||
import * as fs from 'fs/promises';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
export async function getPinUrlFromPinterest(keyword: string): Promise<string | null> {
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36');
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
try {
|
||||
const searchUrl = `https://www.pinterest.com/search/pins/?q=${encodeURIComponent(keyword)}`;
|
||||
await page.goto(searchUrl, { waitUntil: 'networkidle2' });
|
||||
|
||||
const scrollCount = Math.floor(Math.random() * 5) + 1;
|
||||
logger.info(`Scrolling ${scrollCount} times...`);
|
||||
for (let i = 0; i < scrollCount; i++) {
|
||||
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
|
||||
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000 + 1000));
|
||||
}
|
||||
|
||||
const pinLinks = await page.$$eval('a', (anchors) =>
|
||||
anchors.map((a) => a.href).filter((href) => href.includes('/pin/'))
|
||||
);
|
||||
|
||||
if (pinLinks.length > 0) {
|
||||
return pinLinks[Math.floor(Math.random() * pinLinks.length)];
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('Error while getting pin URL from Pinterest:', error);
|
||||
return null;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Download up to `count` images from a pin URL by opening the pin page and scro lling up to 5 times to trigger lazy loading
|
||||
// Returns an array of saved image paths (may be empty)
|
||||
export async function downloadImageFromPin(pinUrl: string, count: number = 1): Promise<string[]> {
|
||||
const browser = await puppeteer.launch({ headless: false });
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36');
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
try {
|
||||
await page.goto(pinUrl, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
|
||||
await new Promise((r) => setTimeout(r, 700 + Math.random() * 800));
|
||||
}
|
||||
|
||||
const imgs: string[] = await page.$$eval('img', imgs => {
|
||||
// For each <img> try to extract the 4x (original) URL from srcset.
|
||||
// srcset example:
|
||||
// "https://i.pinimg.com/236x/...jpg 1x, https://i.pinimg.com/474x/...jpg 2x, https://i.pinimg.com/736x/...jpg 3x, https://i.pinimg.com/originals/...jpg 4x"
|
||||
const urls: string[] = imgs.map(img => {
|
||||
const srcset = (img as HTMLImageElement).getAttribute('srcset') || '';
|
||||
if (!srcset) return '';
|
||||
const parts = srcset.split(',').map(p => p.trim());
|
||||
for (const part of parts) {
|
||||
const m = part.match(/^(\S+)\s+4x$/);
|
||||
if (m && m[1]) return m[1];
|
||||
}
|
||||
// fallback: if src contains "originals" return src
|
||||
const src = (img as HTMLImageElement).src || '';
|
||||
if (src.includes('/originals/')) return src;
|
||||
return '';
|
||||
}).filter(s => !!s && s.includes('pinimg'));
|
||||
return urls;
|
||||
});
|
||||
|
||||
if (!imgs || imgs.length === 0) {
|
||||
logger.warn(`No image src (4x) found on pin page ${pinUrl}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// shuffle and pick up to `count` unique images
|
||||
const shuffled = imgs.slice().sort(() => 0.5 - Math.random());
|
||||
const chosen = shuffled.slice(0, Math.min(count, shuffled.length));
|
||||
|
||||
const outDir = path.join(process.cwd(), 'download');
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
const results: string[] = [];
|
||||
for (let i = 0; i < chosen.length; i++) {
|
||||
const src = chosen[i];
|
||||
try {
|
||||
const imgPage = await browser.newPage();
|
||||
const resp = await imgPage.goto(src, { timeout: 30000, waitUntil: 'networkidle2' });
|
||||
if (!resp) {
|
||||
logger.warn(`Failed to fetch image ${src} from ${pinUrl}`);
|
||||
await imgPage.close();
|
||||
continue;
|
||||
}
|
||||
const buffer = await resp.buffer();
|
||||
const pinId = pinUrl.split('/').filter(Boolean).pop() || `pin_${Date.now()}`;
|
||||
const timestamp = Date.now();
|
||||
const outPath = path.join(outDir, `${pinId}_${timestamp}_${i}.png`);
|
||||
await fs.writeFile(outPath, buffer);
|
||||
results.push(outPath);
|
||||
await imgPage.close();
|
||||
} catch (err) {
|
||||
logger.error(`Failed to download image ${src} from ${pinUrl}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to download images from ${pinUrl}:`, err);
|
||||
return [];
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user