cangku/backend/src/services/pddDraftAttributePreparationService.js

240 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
};