feat: AI生图功能丰富

This commit is contained in:
2026-09-27 22:41:33 +08:00
parent 1c4fe1a836
commit 7f1503294f
20 changed files with 627 additions and 336 deletions
+52 -2
View File
@@ -5,6 +5,8 @@
import type { TextMaterial } from '../collector/merge';
export interface ReportProductPayload {
/** 后续批次写入首次上报创建的商品;调用方首次上报无需提供。 */
product_id?: string;
source: {
platform: string;
itemId: string | null;
@@ -22,16 +24,24 @@ export interface ReportProductPayload {
}>;
/** 下载源图时需带的 Referer origin(如 https://detail.1688.com) */
refererOrigin?: string;
/** 多批上报时,仅最后一批启动素材下载,避免同一商品出现并发重复下载。 */
process_assets?: boolean;
}
export interface ReportProductResult {
product_id: string;
stage?: string;
assets_queued?: number;
assets_skipped?: number;
}
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS) */
export async function reportProduct(
/**
* 后端单次最多接收 100 个素材;插件在此自动拆批,用户无需手动删减或分批上报。
* 所有批次共用首次返回的 product_id,且只在最后一批启动下载队列。
*/
const MATERIALS_BATCH_SIZE = 100;
async function postMaterials(
reportBaseUrl: string,
payload: ReportProductPayload,
): Promise<ReportProductResult> {
@@ -44,3 +54,43 @@ export async function reportProduct(
if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`);
return data as ReportProductResult;
}
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS)。 */
export async function reportProduct(
reportBaseUrl: string,
payload: ReportProductPayload,
): Promise<ReportProductResult> {
const batches = payload.images.length > 0
? Array.from({ length: Math.ceil(payload.images.length / MATERIALS_BATCH_SIZE) }, (_, index) =>
payload.images.slice(index * MATERIALS_BATCH_SIZE, (index + 1) * MATERIALS_BATCH_SIZE),
)
: [[]];
let productId = payload.product_id;
let lastResult: ReportProductResult | null = null;
let assetsQueued = 0;
let assetsSkipped = 0;
for (let index = 0; index < batches.length; index += 1) {
try {
const result = await postMaterials(reportBaseUrl, {
...payload,
product_id: productId,
// 文本仅需在首批写入;后续批次只追加素材,避免覆盖人工编辑的信息。
texts: index === 0 ? payload.texts : [],
images: batches[index],
process_assets: index === batches.length - 1,
});
productId = result.product_id;
lastResult = result;
assetsQueued += result.assets_queued ?? 0;
assetsSkipped += result.assets_skipped ?? 0;
} catch (error) {
const prefix = batches.length > 1 ? `第 ${index + 1}/${batches.length} 批上报失败:` : '';
throw new Error(prefix + (error instanceof Error ? error.message : String(error)));
}
}
if (!lastResult) throw new Error('上报未返回商品信息');
return { ...lastResult, assets_queued: assetsQueued, assets_skipped: assetsSkipped };
}
@@ -7,20 +7,64 @@ import type { ImageMaterial } from './image';
import type { SiteProfile } from '../profiles/types';
const IMAGE_ATTRS = ['src', 'data-src', 'data-lazyload-src', 'data-original', 'data-lazy', 'data-ks-lazyload'];
const IMAGE_EXTENSION_RE = /\.(?:avif|bmp|gif|jpe?g|png|webp)$/i;
const IMAGE_EXTENSION_IN_PATH_RE = /\.(?:avif|bmp|gif|jpe?g|png|webp)\//i;
function normalizeEscapedText(raw: string): string {
return raw
.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
// 1688/淘宝详情接口常把 HTML 放在 JSON/JS 字符串中。若不还原 \",DOMParser
// 会把它编码为 %22,并可能把后面的真实 https URL 误解析成当前 CDN 的路径。
.replace(/\\(["'])/g, '$1')
.replace(/\\\//g, '/');
}
function trimImageCandidate(value: string): string {
return value
.trim()
.replace(/[),;]+$/, '')
// “...jpg/” 是详情字符串错误拼接的常见形态,不是有效图片路径。
.replace(/(\.(?:avif|bmp|gif|jpe?g|png|webp))\/+$/i, '$1');
}
/**
* 修复被外层 CDN 路径/转义引号包裹的图片地址,例如:
* https://itemcdn.tmall.com/%22https://cbu01.alicdn.com/img/...jpg/
* 仅在检测到两个 URL 且内层是标准图片路径时取内层,正常带签名参数的 URL 保持原样。
*/
function recoverEmbeddedImageUrl(value: string): string | null {
let decoded = value;
try {
decoded = decodeURIComponent(value);
} catch {
// 非法百分号编码时仍按原文解析。
}
const matches = decoded.match(/https?:\/\/[^\s'"<>\\]+/gi) ?? [];
if (matches.length < 2) return null;
for (const match of matches.reverse()) {
const candidate = trimImageCandidate(match);
try {
const url = new URL(candidate);
if (/^https?:$/i.test(url.protocol) && IMAGE_EXTENSION_RE.test(url.pathname)) return url.href;
} catch {
// 继续检查更外层的候选。
}
}
return null;
}
function normalizeUrl(raw: string, baseUrl: string): string | null {
const value = raw.trim().replace(/^['"]|['"]$/g, '');
const cleaned = raw.trim().replace(/^['"]|['"]$/g, '');
const value = recoverEmbeddedImageUrl(cleaned) ?? cleaned;
if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null;
try {
const url = new URL(value, baseUrl);
return /^https?:$/i.test(url.protocol) ? url.href : null;
if (!/^https?:$/i.test(url.protocol)) return null;
// 不允许图片扩展名后仍跟路径段(如 .jpg/),该 URL 不会指向图片资源。
if (IMAGE_EXTENSION_IN_PATH_RE.test(url.pathname)) return null;
return url.href;
} catch {
return null;
}
@@ -53,7 +97,7 @@ export function extractDetailImageUrls(raw: string, baseUrl: string): string[] {
// 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。
const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? [];
for (const candidate of candidates) {
const url = normalizeUrl(candidate.replace(/[),;]+$/, ''), baseUrl);
const url = normalizeUrl(trimImageCandidate(candidate), baseUrl);
if (!url) continue;
const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname);
const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url);