forked from lunanting/cangku
240 lines
7.8 KiB
JavaScript
240 lines
7.8 KiB
JavaScript
'use strict';
|
||
|
||
const axios = require('axios');
|
||
const { getSimilarProductsRuntimeConfig } = require('./similarProductsSettings');
|
||
const { getSourceImages } = require('./pddOpenPlatformDraftPublisher');
|
||
const { generatePddPropertyRecommendations } = require('./selectionFissionAiSettings');
|
||
const {
|
||
mergePropertyRecommendations,
|
||
resolveExcelMappedProperties,
|
||
} = require('./pdd1688PropertyMappingService');
|
||
|
||
function normalizeText(value, maxLength = 1000) {
|
||
return String(value ?? '').trim().slice(0, maxLength);
|
||
}
|
||
|
||
function normalizeImageUrl(value) {
|
||
return normalizeText(value, 4000).replace(/_sum\.jpg(?=([?#]|$))/gi, '');
|
||
}
|
||
|
||
function collectPddAttributeImages(item = {}) {
|
||
const sourceImages = getSourceImages(item);
|
||
const sourceProduct = item?.metadata?.sourceRow?.product || item?.selectedSpuPayload || {};
|
||
const pricingBySku = new Map((item.pricingRows || []).map((row) => [
|
||
normalizeText(row.skuCode, 160).toUpperCase(),
|
||
row,
|
||
]));
|
||
const sceneBySku = new Map((item.skuSceneImages?.rows || []).map((row) => [
|
||
normalizeText(row.skuCode, 160).toUpperCase(),
|
||
row,
|
||
]));
|
||
const selectedCodes = new Set((item.skuSceneImages?.selectedSkuCodes || [])
|
||
.map((value) => normalizeText(value, 160).toUpperCase())
|
||
.filter(Boolean));
|
||
const skuRows = (item.skuSnapshot?.rows || []).filter((row) => (
|
||
selectedCodes.size === 0 || selectedCodes.has(normalizeText(row.skuCode, 160).toUpperCase())
|
||
));
|
||
const groups = [
|
||
{ kind: 'main', label: '主图', urls: sourceImages.mainImages },
|
||
{
|
||
kind: 'detail',
|
||
label: '详情图',
|
||
urls: Array.isArray(sourceProduct.detailImages)
|
||
? sourceProduct.detailImages
|
||
: sourceImages.detailImages,
|
||
},
|
||
{
|
||
kind: 'sku',
|
||
label: 'SKU图',
|
||
urls: skuRows.map((row) => {
|
||
const code = normalizeText(row.skuCode, 160).toUpperCase();
|
||
return pricingBySku.get(code)?.generatedImageUrl
|
||
|| sceneBySku.get(code)?.generatedImageUrl
|
||
|| row.skuImageUrl;
|
||
}),
|
||
},
|
||
];
|
||
const seen = new Set();
|
||
return groups.flatMap((group) => {
|
||
let groupIndex = 0;
|
||
return (group.urls || []).map(normalizeImageUrl).filter((url) => {
|
||
if (!url || seen.has(url)) return false;
|
||
seen.add(url);
|
||
return true;
|
||
}).map((url) => {
|
||
groupIndex += 1;
|
||
return { kind: group.kind, label: group.label, index: groupIndex, url };
|
||
});
|
||
});
|
||
}
|
||
|
||
async function recognizeImageText(image, options = {}) {
|
||
const config = options.config || await getSimilarProductsRuntimeConfig();
|
||
if (!config.endpointUrl || !config.token) {
|
||
const error = new Error('以图搜图OCR接口地址或API Key未配置');
|
||
error.status = 409;
|
||
error.code = 'PDD_ATTRIBUTE_OCR_NOT_CONFIGURED';
|
||
throw error;
|
||
}
|
||
const httpClient = options.httpClient || axios;
|
||
const response = await httpClient.post(config.endpointUrl, {
|
||
image_url: image.url,
|
||
top_k: 1,
|
||
score_threshold: 0.99,
|
||
search_mode: 'appearance',
|
||
include_raw: false,
|
||
}, {
|
||
timeout: config.timeoutMs,
|
||
headers: {
|
||
Authorization: `Bearer ${config.token}`,
|
||
'X-API-Key': config.token,
|
||
'X-Image-OCR': 'true',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
maxBodyLength: 15 * 1024 * 1024,
|
||
});
|
||
const payload = response?.data || {};
|
||
if (payload.success !== true) {
|
||
const error = new Error(payload.message || payload.error || 'OCR图片文字识别失败');
|
||
error.status = response?.status || 502;
|
||
error.code = payload.error || 'PDD_ATTRIBUTE_OCR_FAILED';
|
||
throw error;
|
||
}
|
||
const ocr = payload.data?.ocr || {};
|
||
if (ocr.requested !== true || ocr.success !== true) {
|
||
const error = new Error(ocr.error || 'OCR服务未返回有效文字识别结果');
|
||
error.status = 502;
|
||
error.code = 'PDD_ATTRIBUTE_OCR_FAILED';
|
||
throw error;
|
||
}
|
||
return {
|
||
...image,
|
||
success: true,
|
||
fullText: normalizeText(ocr.fullText, 12000),
|
||
textCount: Number(ocr.textCount || 0),
|
||
durationMs: Number(ocr.durationMs || 0),
|
||
requestId: normalizeText(payload.request_id, 160),
|
||
};
|
||
}
|
||
|
||
async function preparePddDraftAttributes(context = {}, options = {}) {
|
||
const updateProgress = options.updateProgress || (async () => {});
|
||
const images = collectPddAttributeImages(context.item);
|
||
const ocrResults = [];
|
||
await updateProgress({
|
||
status: 'running',
|
||
stage: 'ocr',
|
||
progress: 2,
|
||
message: `准备读取 ${images.length} 张商品图片`,
|
||
totalImages: images.length,
|
||
completedImages: 0,
|
||
});
|
||
for (let index = 0; index < images.length; index += 1) {
|
||
const image = images[index];
|
||
await updateProgress({
|
||
status: 'running',
|
||
stage: 'ocr',
|
||
progress: Math.round(5 + (index / Math.max(images.length, 1)) * 70),
|
||
message: `当前正在读取第${image.index}张${image.label}的文字信息`,
|
||
currentImage: { kind: image.kind, label: image.label, index: image.index, url: image.url },
|
||
totalImages: images.length,
|
||
completedImages: index,
|
||
});
|
||
try {
|
||
ocrResults.push(await recognizeImageText(image, options));
|
||
} catch (error) {
|
||
ocrResults.push({
|
||
...image,
|
||
success: false,
|
||
fullText: '',
|
||
error: normalizeText(error.message, 1000),
|
||
errorCode: normalizeText(error.code, 120),
|
||
});
|
||
}
|
||
}
|
||
const successCount = ocrResults.filter((row) => row.success).length;
|
||
if (images.length > 0 && successCount === 0) {
|
||
const error = new Error('所有商品图片OCR识别均失败,未调用AI填充属性');
|
||
error.status = 502;
|
||
error.code = 'PDD_ATTRIBUTE_OCR_ALL_FAILED';
|
||
error.data = { ocrResults };
|
||
throw error;
|
||
}
|
||
|
||
await updateProgress({
|
||
status: 'running',
|
||
stage: 'mapping',
|
||
progress: 78,
|
||
message: '正在应用拼多多-1688属性映射规则',
|
||
totalImages: images.length,
|
||
completedImages: images.length,
|
||
});
|
||
const excelResult = resolveExcelMappedProperties({
|
||
categoryName: context.categoryName,
|
||
categoryPath: context.categoryPathNames,
|
||
sourceCategory: context.sourceCategory,
|
||
sourceDescription: context.sourceDescription,
|
||
sourceAttributes: context.sourceAttributes,
|
||
categoryRule: context.categoryRule,
|
||
});
|
||
|
||
await updateProgress({
|
||
status: 'running',
|
||
stage: 'ai',
|
||
progress: 84,
|
||
message: '正在通过qwen3.8-max分析并补全拼多多商品属性',
|
||
totalImages: images.length,
|
||
completedImages: images.length,
|
||
excelMappedCount: excelResult.properties.length,
|
||
});
|
||
const aiResult = await (options.generateRecommendations || generatePddPropertyRecommendations)({
|
||
categoryName: context.categoryName,
|
||
categoryPath: context.categoryPathNames,
|
||
productTitle: context.productTitle,
|
||
sourceCategory: context.sourceCategory,
|
||
sourceAttributes: context.sourceAttributes,
|
||
categoryRule: context.categoryRule,
|
||
lockedRefPids: excelResult.lockedRefPids,
|
||
ocrResults: ocrResults.filter((row) => row.success),
|
||
});
|
||
const properties = mergePropertyRecommendations(
|
||
context.categoryRule,
|
||
excelResult,
|
||
aiResult.properties
|
||
);
|
||
const result = {
|
||
status: 'completed',
|
||
generatedAt: new Date().toISOString(),
|
||
categoryMapping: excelResult.categoryMapping,
|
||
model: aiResult.model,
|
||
properties,
|
||
ocr: {
|
||
total: images.length,
|
||
successCount,
|
||
failureCount: images.length - successCount,
|
||
rows: ocrResults,
|
||
},
|
||
summary: {
|
||
excelMappedCount: properties.filter((row) => row.source === 'excel').length,
|
||
aiFilledCount: properties.filter((row) => row.source === 'ai').length,
|
||
totalFilledCount: properties.length,
|
||
},
|
||
};
|
||
await updateProgress({
|
||
status: 'completed',
|
||
stage: 'confirmation',
|
||
progress: 100,
|
||
message: '智能属性已生成,请运营确认最终上架属性',
|
||
totalImages: images.length,
|
||
completedImages: images.length,
|
||
result,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
module.exports = {
|
||
collectPddAttributeImages,
|
||
preparePddDraftAttributes,
|
||
recognizeImageText,
|
||
};
|