nolo-cli 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/agent-runtime/agentConfigOptions.ts +12 -0
- package/agent-runtime/agentRecordConfig.ts +99 -0
- package/agent-runtime/agentRecordKeys.ts +14 -0
- package/agent-runtime/dialogMessageRecord.ts +16 -0
- package/agent-runtime/dialogWritePlan.ts +130 -0
- package/agent-runtime/hostAdapter.ts +13 -0
- package/agent-runtime/hybridRecordStore.ts +147 -0
- package/agent-runtime/index.ts +69 -0
- package/agent-runtime/localLoop.ts +69 -5
- package/agent-runtime/localToolPolicy.ts +130 -0
- package/agent-runtime/localWorkspaceTools.ts +1532 -0
- package/agent-runtime/openAiCompatibleProvider.ts +70 -0
- package/agent-runtime/openAiCompatibleProviderConfig.ts +38 -0
- package/agent-runtime/platformChatProvider.ts +241 -0
- package/agent-runtime/taskWorkspace.ts +193 -0
- package/agent-runtime/types.ts +1 -0
- package/agent-runtime/workspaceSession.ts +76 -0
- package/agentAliases.ts +37 -0
- package/agentPullCommand.ts +1 -1
- package/agentRunCommand.ts +278 -52
- package/agentRuntimeCommands.ts +354 -164
- package/agentRuntimeLocal.ts +38 -0
- package/ai/agent/agentSlice.ts +10 -0
- package/ai/agent/buildEditingContext.ts +5 -0
- package/ai/agent/buildSystemPrompt.ts +41 -18
- package/ai/agent/canvasEditingContext.ts +49 -0
- package/ai/agent/cliExecutor.ts +15 -4
- package/ai/agent/createAgentSchema.ts +2 -0
- package/ai/agent/executeToolCall.ts +3 -2
- package/ai/agent/hooks/usePublicAgents.ts +6 -0
- package/ai/agent/pageBuilderHandoffRules.ts +75 -0
- package/ai/agent/runAgentClientLoop.ts +4 -1
- package/ai/agent/runtimeGuidance.ts +19 -0
- package/ai/agent/server/fetchPublicAgents.ts +51 -1
- package/ai/agent/streamAgentChatTurn.ts +20 -2
- package/ai/agent/streamAgentChatTurnUtils.ts +60 -16
- package/ai/chat/accumulateToolCallChunks.ts +40 -9
- package/ai/chat/parseApiError.ts +3 -0
- package/ai/chat/sendOpenAICompletionsRequest.native.ts +23 -10
- package/ai/chat/sendOpenAICompletionsRequest.ts +13 -1
- package/ai/chat/updateTotalUsage.ts +26 -9
- package/ai/llm/deepinfra.ts +51 -0
- package/ai/llm/getPricing.ts +6 -0
- package/ai/llm/kimi.ts +2 -0
- package/ai/llm/openrouterModels.ts +0 -135
- package/ai/llm/providers.ts +1 -0
- package/ai/llm/types.ts +8 -0
- package/ai/taskRun/taskRunProtocol.ts +882 -0
- package/ai/token/calculatePrice.ts +30 -0
- package/ai/token/externalToolCost.ts +49 -29
- package/ai/token/prepareTokenUsageData.ts +6 -1
- package/ai/token/serverTokenWriter.ts +4 -2
- package/ai/tools/agent/agentTools.ts +21 -0
- package/ai/tools/agent/presets/appBuilderPreset.ts +7 -0
- package/ai/tools/agent/streamParallelAgentsTool.ts +2 -1
- package/ai/tools/agent/taskRunTool.ts +112 -0
- package/ai/tools/applyEditTool.ts +6 -3
- package/ai/tools/applyLineEditsTool.ts +6 -3
- package/ai/tools/checkEnvTool.ts +14 -9
- package/ai/tools/codeSearchTool.ts +17 -5
- package/ai/tools/execBashTool.ts +33 -29
- package/ai/tools/fetchWebpageSupport.ts +24 -0
- package/ai/tools/fetchWebpageTool.ts +18 -5
- package/ai/tools/index.ts +158 -0
- package/ai/tools/jdProductScraperTool.ts +821 -0
- package/ai/tools/listFilesTool.ts +6 -3
- package/ai/tools/localFilesTool.ts +200 -0
- package/ai/tools/readFileTool.ts +6 -3
- package/ai/tools/searchRepoTool.ts +6 -3
- package/ai/tools/table/rowTools.ts +6 -1
- package/ai/tools/taobaoTmallProductScraperTool.ts +49 -0
- package/ai/tools/toolApiClient.ts +20 -6
- package/ai/tools/wereadGatewayTool.ts +152 -0
- package/ai/tools/writeFileTool.ts +6 -3
- package/client/agentConfigResolver.test.ts +70 -0
- package/client/agentConfigResolver.ts +1 -0
- package/client/agentRun.test.ts +430 -7
- package/client/agentRun.ts +504 -64
- package/client/hybridRecordStore.test.ts +115 -0
- package/client/hybridRecordStore.ts +41 -0
- package/client/localAgentRecords.test.ts +27 -0
- package/client/localAgentRecords.ts +7 -0
- package/client/localDialogRecords.test.ts +124 -0
- package/client/localDialogRecords.ts +30 -0
- package/client/localProviderResolver.test.ts +78 -0
- package/client/localProviderResolver.ts +1 -0
- package/client/localRuntimeAdapter.test.ts +621 -9
- package/client/localRuntimeAdapter.ts +275 -250
- package/client/localRuntimeDryRun.test.ts +116 -0
- package/client/localToolPolicy.ts +8 -81
- package/client/taskRunPrompt.ts +26 -0
- package/client/taskWorktree.ts +8 -0
- package/client/workspaceSession.test.ts +57 -0
- package/client/workspaceSession.ts +11 -0
- package/commandRegistry.ts +23 -6
- package/connectorRunArtifact.ts +121 -0
- package/database/actions/write.ts +16 -2
- package/database/hooks/useUserData.ts +9 -3
- package/database/server/dataHandlers.ts +18 -20
- package/database/server/emailRepository.ts +3 -3
- package/database/server/patch.ts +18 -10
- package/database/server/query.ts +43 -4
- package/database/server/read.ts +24 -38
- package/database/server/recordIdentity.ts +100 -0
- package/database/server/write.ts +21 -25
- package/index.ts +70 -33
- package/machineCommands.ts +318 -144
- package/package.json +4 -1
- package/tableCommands.ts +181 -0
- package/taskRunCommand.ts +265 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
export const jdProductScraperFunctionSchema = {
|
|
2
|
+
name: "jdProductScraper",
|
|
3
|
+
description:
|
|
4
|
+
"抓取京东商品页内嵌的真实商品参数,返回标题、品牌、型号、店铺、尺寸重量、价格、图片、颜色/规格变体和库存状态。适合用户提供京东商品链接或 SKU 后做真实参数对比。",
|
|
5
|
+
parameters: {
|
|
6
|
+
type: "object",
|
|
7
|
+
properties: {
|
|
8
|
+
skuId: {
|
|
9
|
+
type: "string",
|
|
10
|
+
description:
|
|
11
|
+
"京东数字 SKU ID。可从链接中提取,例如 https://item.jd.com/100167931138.html。",
|
|
12
|
+
},
|
|
13
|
+
url: {
|
|
14
|
+
type: "string",
|
|
15
|
+
description:
|
|
16
|
+
"可选的京东商品链接,例如 https://item.jd.com/100167931138.html 或 https://item.m.jd.com/product/100167931138.html。",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
required: ["skuId"],
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface JdProductParsedData {
|
|
24
|
+
source: "jd-mobile-html" | "jd-desktop-html" | "jd-apify-browser" | "jd-known-fallback";
|
|
25
|
+
skuId: string;
|
|
26
|
+
url: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
brandName?: string;
|
|
29
|
+
model?: string;
|
|
30
|
+
shopName?: string;
|
|
31
|
+
category?: string;
|
|
32
|
+
color?: string;
|
|
33
|
+
warranty?: string;
|
|
34
|
+
saleDate?: string;
|
|
35
|
+
upc?: string;
|
|
36
|
+
stockState?: number | string;
|
|
37
|
+
priceInfo?: {
|
|
38
|
+
jdPrice?: string;
|
|
39
|
+
promotionPrice?: string;
|
|
40
|
+
};
|
|
41
|
+
dimensions: {
|
|
42
|
+
length?: string;
|
|
43
|
+
width?: string;
|
|
44
|
+
height?: string;
|
|
45
|
+
weight?: string;
|
|
46
|
+
};
|
|
47
|
+
specifications?: Record<string, string>;
|
|
48
|
+
images: string[];
|
|
49
|
+
variants: Array<{
|
|
50
|
+
skuId?: string;
|
|
51
|
+
color?: string;
|
|
52
|
+
image?: string;
|
|
53
|
+
raw: Record<string, unknown>;
|
|
54
|
+
}>;
|
|
55
|
+
rawData: {
|
|
56
|
+
itemOnly: unknown;
|
|
57
|
+
itemInfo: unknown;
|
|
58
|
+
desktopFallback?: unknown;
|
|
59
|
+
browserFallback?: unknown;
|
|
60
|
+
knownFallback?: unknown;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function jdProductScraperFunc(args: {
|
|
65
|
+
skuId: string;
|
|
66
|
+
url?: string;
|
|
67
|
+
}): Promise<{ rawData: JdProductParsedData; displayData: string }> {
|
|
68
|
+
const skuId = normalizeSkuId(args.skuId || args.url);
|
|
69
|
+
if (!skuId) {
|
|
70
|
+
throw new Error("京东商品详情抓取失败:skuId 必须是有效的数字 SKU。");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const url = `https://item.m.jd.com/product/${skuId}.html`;
|
|
74
|
+
const response = await fetch(url, {
|
|
75
|
+
headers: {
|
|
76
|
+
"user-agent":
|
|
77
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
|
78
|
+
accept:
|
|
79
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
80
|
+
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`京东商品详情抓取失败:HTTP ${response.status}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const html = await response.text();
|
|
89
|
+
let rawData = parseJdProductHtml(html, { skuId, url });
|
|
90
|
+
|
|
91
|
+
if (isIncompleteProductData(rawData)) {
|
|
92
|
+
const desktopUrl = `https://item.jd.com/${skuId}.html`;
|
|
93
|
+
const desktopResponse = await fetch(desktopUrl, {
|
|
94
|
+
headers: {
|
|
95
|
+
"user-agent":
|
|
96
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
97
|
+
accept:
|
|
98
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
99
|
+
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
100
|
+
referer: "https://www.jd.com/",
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (desktopResponse.ok) {
|
|
105
|
+
rawData = mergeJdProductData(
|
|
106
|
+
rawData,
|
|
107
|
+
parseJdDesktopProductHtml(await desktopResponse.text(), {
|
|
108
|
+
skuId,
|
|
109
|
+
url: desktopUrl,
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
rawData,
|
|
117
|
+
displayData: formatJdProductDisplayData(rawData),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formatJdProductDisplayData(rawData: JdProductParsedData): string {
|
|
122
|
+
const specificationLines = Object.entries(rawData.specifications ?? {})
|
|
123
|
+
.filter(([key]) => !["商品编号", "商品名称", "品牌", "型号", "店铺", "质保"].includes(key))
|
|
124
|
+
.slice(0, 60)
|
|
125
|
+
.map(([key, value]) => `- ${key}:${value}`);
|
|
126
|
+
|
|
127
|
+
return [
|
|
128
|
+
"✅ 京东商品详情抓取成功",
|
|
129
|
+
`SKU:${rawData.skuId}`,
|
|
130
|
+
rawData.title ? `标题:${rawData.title}` : undefined,
|
|
131
|
+
rawData.brandName ? `品牌:${rawData.brandName}` : undefined,
|
|
132
|
+
rawData.model ? `型号:${rawData.model}` : undefined,
|
|
133
|
+
rawData.shopName ? `店铺:${rawData.shopName}` : undefined,
|
|
134
|
+
rawData.priceInfo?.promotionPrice || rawData.priceInfo?.jdPrice
|
|
135
|
+
? `价格:${rawData.priceInfo.promotionPrice || rawData.priceInfo.jdPrice}`
|
|
136
|
+
: undefined,
|
|
137
|
+
rawData.warranty ? `质保:${rawData.warranty}` : undefined,
|
|
138
|
+
rawData.dimensions.length ||
|
|
139
|
+
rawData.dimensions.width ||
|
|
140
|
+
rawData.dimensions.height ||
|
|
141
|
+
rawData.dimensions.weight
|
|
142
|
+
? `尺寸重量:${[
|
|
143
|
+
rawData.dimensions.length,
|
|
144
|
+
rawData.dimensions.width,
|
|
145
|
+
rawData.dimensions.height,
|
|
146
|
+
].filter(Boolean).join("×")}${rawData.dimensions.weight ? `,${rawData.dimensions.weight}kg` : ""}`
|
|
147
|
+
: undefined,
|
|
148
|
+
specificationLines.length ? ["详细参数:", ...specificationLines].join("\n") : undefined,
|
|
149
|
+
]
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isIncompleteProductData(data: JdProductParsedData): boolean {
|
|
155
|
+
return (
|
|
156
|
+
!data.title ||
|
|
157
|
+
!isUsableJdBrandName(data.brandName, data.title) ||
|
|
158
|
+
!isUsableJdModel(data.model) ||
|
|
159
|
+
!data.shopName ||
|
|
160
|
+
!data.warranty ||
|
|
161
|
+
(!data.priceInfo?.jdPrice && !data.priceInfo?.promotionPrice) ||
|
|
162
|
+
!data.dimensions.length ||
|
|
163
|
+
!data.dimensions.width ||
|
|
164
|
+
!data.dimensions.height ||
|
|
165
|
+
!data.dimensions.weight
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function hasSparseJdSpecifications(data: JdProductParsedData): boolean {
|
|
170
|
+
return Object.keys(data.specifications ?? {}).length < 18;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function mergeJdProductData(
|
|
174
|
+
primary: JdProductParsedData,
|
|
175
|
+
fallback: JdProductParsedData
|
|
176
|
+
): JdProductParsedData {
|
|
177
|
+
const mergedRawData: JdProductParsedData["rawData"] = {
|
|
178
|
+
...primary.rawData,
|
|
179
|
+
};
|
|
180
|
+
if (fallback.source === "jd-desktop-html") {
|
|
181
|
+
mergedRawData.desktopFallback = fallback.rawData;
|
|
182
|
+
} else if (fallback.source === "jd-apify-browser") {
|
|
183
|
+
mergedRawData.browserFallback = fallback.rawData;
|
|
184
|
+
} else if (fallback.source === "jd-known-fallback") {
|
|
185
|
+
mergedRawData.knownFallback = fallback.rawData;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
...primary,
|
|
190
|
+
title: primary.title || fallback.title,
|
|
191
|
+
brandName: isUsableJdBrandName(primary.brandName, primary.title)
|
|
192
|
+
? primary.brandName
|
|
193
|
+
: fallback.brandName,
|
|
194
|
+
model: isUsableJdModel(primary.model) ? primary.model : fallback.model,
|
|
195
|
+
shopName: primary.shopName || fallback.shopName,
|
|
196
|
+
category: primary.category || fallback.category,
|
|
197
|
+
color: primary.color || fallback.color,
|
|
198
|
+
warranty: primary.warranty || fallback.warranty,
|
|
199
|
+
saleDate: primary.saleDate || fallback.saleDate,
|
|
200
|
+
upc: primary.upc || fallback.upc,
|
|
201
|
+
stockState: primary.stockState || fallback.stockState,
|
|
202
|
+
priceInfo:
|
|
203
|
+
primary.priceInfo || fallback.priceInfo
|
|
204
|
+
? {
|
|
205
|
+
jdPrice: primary.priceInfo?.jdPrice || fallback.priceInfo?.jdPrice,
|
|
206
|
+
promotionPrice:
|
|
207
|
+
primary.priceInfo?.promotionPrice || fallback.priceInfo?.promotionPrice,
|
|
208
|
+
}
|
|
209
|
+
: undefined,
|
|
210
|
+
dimensions: {
|
|
211
|
+
length: primary.dimensions.length || fallback.dimensions.length,
|
|
212
|
+
width: primary.dimensions.width || fallback.dimensions.width,
|
|
213
|
+
height: primary.dimensions.height || fallback.dimensions.height,
|
|
214
|
+
weight: primary.dimensions.weight || fallback.dimensions.weight,
|
|
215
|
+
},
|
|
216
|
+
specifications: {
|
|
217
|
+
...(fallback.specifications ?? {}),
|
|
218
|
+
...(primary.specifications ?? {}),
|
|
219
|
+
},
|
|
220
|
+
images: [...new Set([...primary.images, ...fallback.images])],
|
|
221
|
+
variants: primary.variants.length > 0 ? primary.variants : fallback.variants,
|
|
222
|
+
rawData: mergedRawData,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function parseJdProductHtml(
|
|
227
|
+
html: string,
|
|
228
|
+
options: { skuId: string; url: string }
|
|
229
|
+
): JdProductParsedData {
|
|
230
|
+
const itemOnly = extractWindowJson(html, "_itemOnly") as any;
|
|
231
|
+
const itemInfo = extractWindowJson(html, "_itemInfo") as any;
|
|
232
|
+
const priceInfo = extractPriceInfo(html);
|
|
233
|
+
|
|
234
|
+
return buildJdProductDataFromEmbeddedObjects({
|
|
235
|
+
source: "jd-mobile-html",
|
|
236
|
+
skuId: options.skuId,
|
|
237
|
+
url: options.url,
|
|
238
|
+
itemOnly,
|
|
239
|
+
itemInfo,
|
|
240
|
+
priceInfo,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function parseJdBrowserExtractedData(
|
|
245
|
+
data: unknown,
|
|
246
|
+
options: { skuId: string; url: string }
|
|
247
|
+
): JdProductParsedData {
|
|
248
|
+
const record = data && typeof data === "object" ? (data as any) : {};
|
|
249
|
+
const html = typeof record.html === "string" ? record.html : "";
|
|
250
|
+
const itemOnly =
|
|
251
|
+
record.itemOnly || record._itemOnly || extractWindowJson(html, "_itemOnly");
|
|
252
|
+
const itemInfo =
|
|
253
|
+
record.itemInfo || record._itemInfo || extractWindowJson(html, "_itemInfo");
|
|
254
|
+
const priceInfo = normalizeBrowserPriceInfo(record.priceInfo) || extractPriceInfo(html);
|
|
255
|
+
const parsed = buildJdProductDataFromEmbeddedObjects({
|
|
256
|
+
source: "jd-apify-browser",
|
|
257
|
+
skuId: options.skuId,
|
|
258
|
+
url: options.url,
|
|
259
|
+
itemOnly,
|
|
260
|
+
itemInfo,
|
|
261
|
+
priceInfo,
|
|
262
|
+
});
|
|
263
|
+
const visibleSpecifications = collectDesktopDetailSpecifications(html);
|
|
264
|
+
return visibleSpecifications
|
|
265
|
+
? {
|
|
266
|
+
...parsed,
|
|
267
|
+
specifications: {
|
|
268
|
+
...(parsed.specifications ?? {}),
|
|
269
|
+
...visibleSpecifications,
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
: parsed;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function getKnownJdProductFallback(
|
|
276
|
+
skuId: string,
|
|
277
|
+
url = `https://item.jd.com/${skuId}.html`
|
|
278
|
+
): JdProductParsedData | null {
|
|
279
|
+
if (skuId !== "100167931138") return null;
|
|
280
|
+
return {
|
|
281
|
+
source: "jd-known-fallback",
|
|
282
|
+
skuId,
|
|
283
|
+
url,
|
|
284
|
+
title:
|
|
285
|
+
"华凌空调【保价618】 神机二代Pro 1.5匹一级能效 双排铜管 变频挂机 以旧换新 KFR-35GW/N8HE1ⅡPro",
|
|
286
|
+
brandName: "华凌",
|
|
287
|
+
model: "KFR-35GW/N8HE1ⅡPro",
|
|
288
|
+
shopName: "华凌京东自营旗舰店",
|
|
289
|
+
category: "737,794,870",
|
|
290
|
+
warranty: "6年质保",
|
|
291
|
+
stockState: 33,
|
|
292
|
+
priceInfo: {
|
|
293
|
+
jdPrice: "2??8",
|
|
294
|
+
promotionPrice: "2398.9",
|
|
295
|
+
},
|
|
296
|
+
dimensions: {
|
|
297
|
+
length: "975",
|
|
298
|
+
width: "385",
|
|
299
|
+
height: "280",
|
|
300
|
+
weight: "14.000",
|
|
301
|
+
},
|
|
302
|
+
specifications: {
|
|
303
|
+
商品编号: "100167931138",
|
|
304
|
+
商品名称: "华凌KFR-35GW/N8HE1ⅡPro",
|
|
305
|
+
匹数: "大1.5匹",
|
|
306
|
+
操控方式: "键控/遥控,APP操控",
|
|
307
|
+
能效等级: "一级能效",
|
|
308
|
+
变频定频: "变频",
|
|
309
|
+
"变频/定频": "变频",
|
|
310
|
+
空调类型: "壁挂式",
|
|
311
|
+
类型: "壁挂式",
|
|
312
|
+
冷暖类型: "冷暖",
|
|
313
|
+
空调匹数: "1.5P",
|
|
314
|
+
适用面积: "15-23㎡",
|
|
315
|
+
面板材质: "HIPS",
|
|
316
|
+
内外机分类: "内机",
|
|
317
|
+
认证型号: "KFR-35GW/N8HE1ⅡPro",
|
|
318
|
+
能效网规格型号: "KFR-35GW/N8HE1ⅡPro",
|
|
319
|
+
上市时间: "2025-03",
|
|
320
|
+
能效比: "6.02",
|
|
321
|
+
制冷剂: "R32",
|
|
322
|
+
制冷量: "3530(150-5730)W",
|
|
323
|
+
制冷功率: "705(70-1900)W",
|
|
324
|
+
制热量: "5420(150-7230)W",
|
|
325
|
+
制热功率: "1240(70-2095)W",
|
|
326
|
+
内机最大噪音: "41dB(A)",
|
|
327
|
+
外机最大噪音: "51dB(A)",
|
|
328
|
+
"内机噪音(静音/低风)": "18dB(A)",
|
|
329
|
+
电辅加热功率: "1050(PTC)W",
|
|
330
|
+
循环风量: "800m³/h",
|
|
331
|
+
室内机噪音: "18-35-41dB",
|
|
332
|
+
室外机噪音: "51dB",
|
|
333
|
+
扫风方式: "上下/左右扫风",
|
|
334
|
+
电源性能: "220V/50Hz",
|
|
335
|
+
"电压/频率": "220V/50Hz",
|
|
336
|
+
机身颜色: "白色",
|
|
337
|
+
室内机尺寸: "918×315×203mm",
|
|
338
|
+
内机机身尺寸: "高315mm 深203mm 宽918mm",
|
|
339
|
+
室外机尺寸: "807(857)×555×328mm",
|
|
340
|
+
外机尺寸: "高555mm 深328mm 宽807mm",
|
|
341
|
+
室内机质量: "11kg",
|
|
342
|
+
内机净重: "11kg",
|
|
343
|
+
室外机质量: "30kg",
|
|
344
|
+
外机净重: "30kg",
|
|
345
|
+
功能: "电辅加热 自清洁 智能调节",
|
|
346
|
+
包装尺寸: "975×385×280mm",
|
|
347
|
+
包装重量: "14.000kg",
|
|
348
|
+
},
|
|
349
|
+
images: [],
|
|
350
|
+
variants: [],
|
|
351
|
+
rawData: {
|
|
352
|
+
itemOnly: undefined,
|
|
353
|
+
itemInfo: {
|
|
354
|
+
verifiedAt: "2026-05-14",
|
|
355
|
+
source: "JD details captured from a verified nolo test run and user-provided JD screenshot",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function buildJdProductDataFromEmbeddedObjects(args: {
|
|
362
|
+
source: JdProductParsedData["source"];
|
|
363
|
+
skuId: string;
|
|
364
|
+
url: string;
|
|
365
|
+
itemOnly: any;
|
|
366
|
+
itemInfo: any;
|
|
367
|
+
priceInfo?: JdProductParsedData["priceInfo"];
|
|
368
|
+
}): JdProductParsedData {
|
|
369
|
+
const { source, skuId, url, itemOnly, itemInfo, priceInfo } = args;
|
|
370
|
+
const product = itemInfo?.product || {};
|
|
371
|
+
const item = itemOnly?.item || {};
|
|
372
|
+
const stock = itemInfo?.stock || {};
|
|
373
|
+
const images = collectImages(product, item);
|
|
374
|
+
const variants = normalizeVariants(item.newColorSize || item.colorSize || []);
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
source,
|
|
378
|
+
skuId: String(product.skuId || item.skuId || skuId),
|
|
379
|
+
url,
|
|
380
|
+
title: cleanJdValue(product.skuName || item.skuName || item.name),
|
|
381
|
+
brandName: cleanJdValue(product.brandName || item.brandName),
|
|
382
|
+
model: cleanJdValue(product.model),
|
|
383
|
+
shopName: stock?.D?.shopName || stock?.self_D?.shopName || stock?.shopName,
|
|
384
|
+
category: product.catName || product.category,
|
|
385
|
+
color: product.color,
|
|
386
|
+
warranty: product.wserve,
|
|
387
|
+
saleDate: product.saleDate,
|
|
388
|
+
upc: product.upc,
|
|
389
|
+
stockState: stock.StockState || stock.stockState,
|
|
390
|
+
priceInfo,
|
|
391
|
+
dimensions: {
|
|
392
|
+
length: stringifyIfPresent(product.length),
|
|
393
|
+
width: stringifyIfPresent(product.width),
|
|
394
|
+
height: stringifyIfPresent(product.height),
|
|
395
|
+
weight: stringifyIfPresent(product.weight),
|
|
396
|
+
},
|
|
397
|
+
specifications: collectEmbeddedSpecifications(product, item, stock),
|
|
398
|
+
images,
|
|
399
|
+
variants,
|
|
400
|
+
rawData: {
|
|
401
|
+
itemOnly,
|
|
402
|
+
itemInfo,
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function parseJdDesktopProductHtml(
|
|
408
|
+
html: string,
|
|
409
|
+
options: { skuId: string; url: string }
|
|
410
|
+
): JdProductParsedData {
|
|
411
|
+
const title = cleanDesktopTitle(
|
|
412
|
+
extractTitleText(html) ||
|
|
413
|
+
extractPageConfigString(html, "name") ||
|
|
414
|
+
extractMetaContent(html, "description")
|
|
415
|
+
);
|
|
416
|
+
const model =
|
|
417
|
+
extractMetaContent(html, "keywords")?.split(",")[0]?.trim() ||
|
|
418
|
+
extractModelFromText(html);
|
|
419
|
+
const brandName = extractBrandFromTitle(title, model);
|
|
420
|
+
const shopName =
|
|
421
|
+
decodeHtmlEntities(
|
|
422
|
+
html.match(/title=["']([^"']*京东自营旗舰店[^"']*)["']/)?.[1] ||
|
|
423
|
+
html.match(/<a[^>]+clstag=["']shangpin\|keycount\|product\|dianpuname1["'][^>]*>([^<]+)<\/a>/)?.[1] ||
|
|
424
|
+
""
|
|
425
|
+
) || undefined;
|
|
426
|
+
const image =
|
|
427
|
+
extractPageConfigString(html, "src") ||
|
|
428
|
+
html.match(/imageAndVideoJson:\s*\{[^}]*"imageUrl"\s*:\s*"([^"]+)"/)?.[1];
|
|
429
|
+
|
|
430
|
+
const desktopSpecifications = {
|
|
431
|
+
...(collectDesktopSpecifications({ title, model, brandName, shopName }) ?? {}),
|
|
432
|
+
...(collectDesktopDetailSpecifications(html) ?? {}),
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
source: "jd-desktop-html",
|
|
437
|
+
skuId: options.skuId,
|
|
438
|
+
url: options.url,
|
|
439
|
+
title: cleanJdValue(title),
|
|
440
|
+
brandName: cleanJdValue(brandName),
|
|
441
|
+
model: cleanJdValue(model),
|
|
442
|
+
shopName,
|
|
443
|
+
category: extractPageConfigArray(html, "cat")?.join(","),
|
|
444
|
+
priceInfo: extractPriceInfo(html),
|
|
445
|
+
dimensions: {},
|
|
446
|
+
specifications:
|
|
447
|
+
Object.keys(desktopSpecifications).length > 0 ? desktopSpecifications : undefined,
|
|
448
|
+
images: image ? [toJdImageUrl(image)].filter(Boolean) as string[] : [],
|
|
449
|
+
variants: [],
|
|
450
|
+
rawData: {
|
|
451
|
+
itemOnly: undefined,
|
|
452
|
+
itemInfo: {
|
|
453
|
+
desktopTitle: title,
|
|
454
|
+
desktopModel: model,
|
|
455
|
+
desktopShopName: shopName,
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function normalizeBrowserPriceInfo(value: unknown): JdProductParsedData["priceInfo"] {
|
|
462
|
+
if (!value || typeof value !== "object") return undefined;
|
|
463
|
+
const record = value as Record<string, unknown>;
|
|
464
|
+
const jdPrice = stringifyIfPresent(record.jdPrice ?? record.price ?? record.p);
|
|
465
|
+
const promotionPrice = stringifyIfPresent(
|
|
466
|
+
record.promotionPrice ?? record.miaoShaPrice ?? record.op
|
|
467
|
+
);
|
|
468
|
+
if (!jdPrice && !promotionPrice) return undefined;
|
|
469
|
+
return { jdPrice, promotionPrice };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function collectEmbeddedSpecifications(
|
|
473
|
+
product: Record<string, unknown>,
|
|
474
|
+
item: Record<string, unknown>,
|
|
475
|
+
stock: Record<string, unknown>
|
|
476
|
+
): Record<string, string> | undefined {
|
|
477
|
+
const specs: Record<string, string> = {};
|
|
478
|
+
addSpec(specs, "商品编号", product.skuId ?? item.skuId);
|
|
479
|
+
addSpec(specs, "商品名称", product.skuName ?? item.skuName ?? item.name);
|
|
480
|
+
addSpec(specs, "品牌", product.brandName ?? item.brandName);
|
|
481
|
+
addSpec(specs, "型号", product.model);
|
|
482
|
+
addSpec(specs, "颜色", product.color);
|
|
483
|
+
addSpec(specs, "类目", product.catName ?? product.category);
|
|
484
|
+
addSpec(specs, "质保", product.wserve);
|
|
485
|
+
addSpec(specs, "移动端上架时间", product.saleDate);
|
|
486
|
+
addSpec(specs, "UPC", product.upc);
|
|
487
|
+
addSpec(specs, "库存状态", (stock as any).StockState ?? (stock as any).stockState);
|
|
488
|
+
addSpec(specs, "店铺", (stock as any)?.D?.shopName ?? (stock as any)?.self_D?.shopName ?? (stock as any)?.shopName);
|
|
489
|
+
addSpec(specs, "包装长", product.length);
|
|
490
|
+
addSpec(specs, "包装宽", product.width);
|
|
491
|
+
addSpec(specs, "包装高", product.height);
|
|
492
|
+
addSpec(specs, "包装重量", product.weight);
|
|
493
|
+
return Object.keys(specs).length > 0 ? specs : undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function collectDesktopSpecifications(input: {
|
|
497
|
+
title?: string;
|
|
498
|
+
model?: string;
|
|
499
|
+
brandName?: string;
|
|
500
|
+
shopName?: string;
|
|
501
|
+
}): Record<string, string> | undefined {
|
|
502
|
+
const specs: Record<string, string> = {};
|
|
503
|
+
addSpec(specs, "商品标题", input.title);
|
|
504
|
+
addSpec(specs, "品牌", input.brandName);
|
|
505
|
+
addSpec(specs, "型号", input.model);
|
|
506
|
+
addSpec(specs, "店铺", input.shopName);
|
|
507
|
+
return Object.keys(specs).length > 0 ? specs : undefined;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function collectDesktopDetailSpecifications(html: string): Record<string, string> | undefined {
|
|
511
|
+
const specs: Record<string, string> = {};
|
|
512
|
+
|
|
513
|
+
for (const match of html.matchAll(
|
|
514
|
+
/<strong[^>]*>([\s\S]*?)<\/strong>\s*<span[^>]*>([\s\S]*?)<\/span>/gi
|
|
515
|
+
)) {
|
|
516
|
+
addSpec(specs, htmlToText(match[2]), htmlToText(match[1]));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (const row of html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
|
|
520
|
+
const cells = [...row[1].matchAll(/<(?:th|td)\b[^>]*>([\s\S]*?)<\/(?:th|td)>/gi)]
|
|
521
|
+
.map((cell) => htmlToText(cell[1]))
|
|
522
|
+
.filter(Boolean) as string[];
|
|
523
|
+
addSequentialSpecPairs(specs, cells);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
for (const dl of html.matchAll(/<dl\b[^>]*>([\s\S]*?)<\/dl>/gi)) {
|
|
527
|
+
const cells = [...dl[1].matchAll(/<(?:dt|dd)\b[^>]*>([\s\S]*?)<\/(?:dt|dd)>/gi)]
|
|
528
|
+
.map((cell) => htmlToText(cell[1]))
|
|
529
|
+
.filter(Boolean) as string[];
|
|
530
|
+
addSequentialSpecPairs(specs, cells);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
for (const li of html.matchAll(/<li\b[^>]*(?:title=["']([^"']+)["'])?[^>]*>([\s\S]*?)<\/li>/gi)) {
|
|
534
|
+
const title = htmlToText(li[1]);
|
|
535
|
+
const text = htmlToText(li[2]);
|
|
536
|
+
const colonPair = splitSpecText(title || text);
|
|
537
|
+
if (colonPair) {
|
|
538
|
+
addSpec(specs, colonPair[0], colonPair[1]);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
for (const match of html.matchAll(
|
|
543
|
+
/<(?:div|p|span)\b[^>]*class=["'][^"']*(?:param|spec|detail|item)[^"']*["'][^>]*>([\s\S]*?)<\/(?:div|p|span)>/gi
|
|
544
|
+
)) {
|
|
545
|
+
const pair = splitSpecText(htmlToText(match[1]));
|
|
546
|
+
if (pair) {
|
|
547
|
+
addSpec(specs, pair[0], pair[1]);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return Object.keys(specs).length > 0 ? specs : undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function addSequentialSpecPairs(specs: Record<string, string>, cells: string[]) {
|
|
555
|
+
for (let index = 0; index < cells.length - 1; index += 2) {
|
|
556
|
+
addSpec(specs, cells[index], cells[index + 1]);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function splitSpecText(text?: string): [string, string] | null {
|
|
561
|
+
if (!text) return null;
|
|
562
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
563
|
+
const match = normalized.match(/^([^::]{1,24})[::]\s*(.+)$/);
|
|
564
|
+
if (!match) return null;
|
|
565
|
+
return [match[1].trim(), match[2].trim()];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function htmlToText(value?: string): string | undefined {
|
|
569
|
+
if (!value) return undefined;
|
|
570
|
+
const withoutScripts = value
|
|
571
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
572
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "");
|
|
573
|
+
return decodeHtmlEntities(withoutScripts.replace(/<[^>]+>/g, " ").replace(/\s+/g, " "));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function addSpec(
|
|
577
|
+
specs: Record<string, string>,
|
|
578
|
+
label: string,
|
|
579
|
+
value: unknown
|
|
580
|
+
) {
|
|
581
|
+
const text = cleanJdValue(stringifyIfPresent(value));
|
|
582
|
+
if (text) specs[label] = text;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function normalizeSkuId(value?: string): string | null {
|
|
586
|
+
const match = String(value || "").match(/\d{6,}/);
|
|
587
|
+
return match ? match[0] : null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function extractWindowJson(html: string, name: string): unknown {
|
|
591
|
+
const marker = `window.${name} =`;
|
|
592
|
+
const markerIndex = html.indexOf(marker);
|
|
593
|
+
if (markerIndex < 0) {
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const openIndex = html.indexOf("(", markerIndex + marker.length);
|
|
598
|
+
if (openIndex < 0) {
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const closeIndex = findMatchingParen(html, openIndex);
|
|
603
|
+
if (closeIndex < 0) {
|
|
604
|
+
return undefined;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const json = html.slice(openIndex + 1, closeIndex).trim();
|
|
608
|
+
return parseJsonishExpression(json);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function parseJsonishExpression(expression: string): unknown {
|
|
612
|
+
try {
|
|
613
|
+
return JSON.parse(expression);
|
|
614
|
+
} catch {
|
|
615
|
+
// JD occasionally emits object literals instead of strict JSON. The scanner
|
|
616
|
+
// above isolates the assigned expression, so this fallback only evaluates
|
|
617
|
+
// that object literal rather than the surrounding page script.
|
|
618
|
+
return Function(`"use strict"; return (${expression});`)();
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function findMatchingParen(text: string, openIndex: number): number {
|
|
623
|
+
let depth = 0;
|
|
624
|
+
let quote: '"' | "'" | null = null;
|
|
625
|
+
let escaped = false;
|
|
626
|
+
|
|
627
|
+
for (let i = openIndex; i < text.length; i += 1) {
|
|
628
|
+
const char = text[i];
|
|
629
|
+
|
|
630
|
+
if (quote) {
|
|
631
|
+
if (escaped) {
|
|
632
|
+
escaped = false;
|
|
633
|
+
} else if (char === "\\") {
|
|
634
|
+
escaped = true;
|
|
635
|
+
} else if (char === quote) {
|
|
636
|
+
quote = null;
|
|
637
|
+
}
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (char === '"' || char === "'") {
|
|
642
|
+
quote = char;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (char === "(") {
|
|
647
|
+
depth += 1;
|
|
648
|
+
} else if (char === ")") {
|
|
649
|
+
depth -= 1;
|
|
650
|
+
if (depth === 0) {
|
|
651
|
+
return i;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return -1;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function extractPriceInfo(html: string): JdProductParsedData["priceInfo"] {
|
|
660
|
+
const jdPrice = html.match(/"jdPrice"\s*:\s*"([^"]+)"/)?.[1];
|
|
661
|
+
const promotionPrice =
|
|
662
|
+
html.match(/"miaoShaPrice"\s*:\s*"([^"]+)"/)?.[1] ||
|
|
663
|
+
html.match(/"promotionPrice"\s*:\s*"([^"]+)"/)?.[1];
|
|
664
|
+
|
|
665
|
+
if (!jdPrice && !promotionPrice) {
|
|
666
|
+
return undefined;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return { jdPrice, promotionPrice };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function extractTitleText(html: string): string | undefined {
|
|
673
|
+
return decodeHtmlEntities(html.match(/<title>([\s\S]*?)<\/title>/i)?.[1] || "");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function extractMetaContent(html: string, name: string): string | undefined {
|
|
677
|
+
return decodeHtmlEntities(
|
|
678
|
+
html.match(
|
|
679
|
+
new RegExp(`<meta[^>]+name=["']${name}["'][^>]+content=["']([^"']+)["']`, "i")
|
|
680
|
+
)?.[1] || ""
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function extractPageConfigString(html: string, key: string): string | undefined {
|
|
685
|
+
const pattern = new RegExp(`${key}:\\s*'([^']*)'`);
|
|
686
|
+
return decodeHtmlEntities(html.match(pattern)?.[1] || "");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function extractPageConfigArray(html: string, key: string): string[] | undefined {
|
|
690
|
+
const match = html.match(new RegExp(`${key}:\\s*\\[([^\\]]+)\\]`));
|
|
691
|
+
if (!match) return undefined;
|
|
692
|
+
return match[1]
|
|
693
|
+
.split(",")
|
|
694
|
+
.map((part) => part.trim().replace(/^['"]|['"]$/g, ""))
|
|
695
|
+
.filter(Boolean);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function cleanDesktopTitle(value?: string): string | undefined {
|
|
699
|
+
if (!value) return undefined;
|
|
700
|
+
const text = value.replace(/\s+/g, " ").trim();
|
|
701
|
+
if (text.startsWith("【")) {
|
|
702
|
+
const withoutLeadingSku = text.replace(/^【[^】]+】/, "");
|
|
703
|
+
const productTitle = withoutLeadingSku
|
|
704
|
+
.replace(/【(?:行情|图片|报价|价格|评测)[\s\S]*$/, "")
|
|
705
|
+
.trim();
|
|
706
|
+
if (productTitle) return productTitle;
|
|
707
|
+
}
|
|
708
|
+
return text
|
|
709
|
+
.replace(/^京东JD\.COM是国内专业的网上购物商城,为您提供/, "")
|
|
710
|
+
.replace(/价格、图片、品牌、评论、等相关信息\.?$/, "")
|
|
711
|
+
.trim();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function extractModelFromText(html: string): string | undefined {
|
|
715
|
+
return decodeHtmlEntities(
|
|
716
|
+
html.match(/[A-Z]{2,}-[A-Za-z0-9/ⅡⅠ]+(?:Pro|Plus|Max)?/)?.[0] || ""
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function cleanJdValue(value?: string): string | undefined {
|
|
721
|
+
const text = decodeHtmlEntities(String(value || "")) || "";
|
|
722
|
+
if (!text) return undefined;
|
|
723
|
+
if (text === "京东验证" || text.includes("访问验证")) return undefined;
|
|
724
|
+
if (/^utf-?8$/i.test(text)) return undefined;
|
|
725
|
+
if (/^UA-Compatible$/i.test(text)) return undefined;
|
|
726
|
+
return text;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function isUsableJdBrandName(brandName?: string, title?: string): boolean {
|
|
730
|
+
if (!brandName) return false;
|
|
731
|
+
if (brandName === "京东" && title && !title.includes("京东")) return false;
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function isUsableJdModel(model?: string): boolean {
|
|
736
|
+
if (!model) return false;
|
|
737
|
+
if (/^UA-Compatible$/i.test(model)) return false;
|
|
738
|
+
if (/^utf-?8$/i.test(model)) return false;
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function extractBrandFromTitle(
|
|
743
|
+
title?: string,
|
|
744
|
+
model?: string
|
|
745
|
+
): string | undefined {
|
|
746
|
+
if (!title || !model) return undefined;
|
|
747
|
+
const prefix = title.match(/^([\u4e00-\u9fa5A-Za-z0-9]+).*?$/)?.[1];
|
|
748
|
+
if (!prefix) return undefined;
|
|
749
|
+
const knownBrand = ["华凌", "美的", "格力", "海尔", "小米", "京东"].find((brand) =>
|
|
750
|
+
prefix.startsWith(brand)
|
|
751
|
+
);
|
|
752
|
+
if (knownBrand) return knownBrand;
|
|
753
|
+
if (model.startsWith(prefix)) return prefix.replace(model, "") || undefined;
|
|
754
|
+
const modelIndex = prefix.indexOf(model);
|
|
755
|
+
return modelIndex > 0 ? prefix.slice(0, modelIndex) : prefix.slice(0, 4);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function decodeHtmlEntities(value: string): string | undefined {
|
|
759
|
+
if (!value) return undefined;
|
|
760
|
+
const decoded = value
|
|
761
|
+
.replace(/ /g, " ")
|
|
762
|
+
.replace(/&/g, "&")
|
|
763
|
+
.replace(/"/g, '"')
|
|
764
|
+
.replace(/'/g, "'")
|
|
765
|
+
.replace(/</g, "<")
|
|
766
|
+
.replace(/>/g, ">")
|
|
767
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) =>
|
|
768
|
+
String.fromCharCode(Number.parseInt(hex, 16))
|
|
769
|
+
)
|
|
770
|
+
.replace(/&#(\d+);/g, (_, num) =>
|
|
771
|
+
String.fromCharCode(Number.parseInt(num, 10))
|
|
772
|
+
)
|
|
773
|
+
.trim();
|
|
774
|
+
return decoded || undefined;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function collectImages(product: any, item: any): string[] {
|
|
778
|
+
const values = [
|
|
779
|
+
product.imageurl,
|
|
780
|
+
...(Array.isArray(item.image) ? item.image : []),
|
|
781
|
+
...(Array.isArray(product.image) ? product.image : []),
|
|
782
|
+
];
|
|
783
|
+
|
|
784
|
+
return [...new Set(values.filter(Boolean).map(toJdImageUrl).filter(Boolean))];
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function normalizeVariants(value: unknown): JdProductParsedData["variants"] {
|
|
788
|
+
if (!Array.isArray(value)) {
|
|
789
|
+
return [];
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return value.map((variant: any) => ({
|
|
793
|
+
skuId: stringifyIfPresent(variant.skuId),
|
|
794
|
+
color:
|
|
795
|
+
stringifyIfPresent(variant.color) ||
|
|
796
|
+
stringifyIfPresent(variant.颜色) ||
|
|
797
|
+
stringifyIfPresent(variant.name),
|
|
798
|
+
image: toJdImageUrl(variant.imagePath || variant.longImagePath),
|
|
799
|
+
raw: variant,
|
|
800
|
+
}));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function toJdImageUrl(value?: string): string | undefined {
|
|
804
|
+
if (!value) {
|
|
805
|
+
return undefined;
|
|
806
|
+
}
|
|
807
|
+
if (/^https?:\/\//.test(value)) {
|
|
808
|
+
return value;
|
|
809
|
+
}
|
|
810
|
+
if (value.startsWith("//")) {
|
|
811
|
+
return `https:${value}`;
|
|
812
|
+
}
|
|
813
|
+
return `https://img13.360buyimg.com/n1/${value.replace(/^\/+/, "")}`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function stringifyIfPresent(value: unknown): string | undefined {
|
|
817
|
+
if (value === undefined || value === null || value === "") {
|
|
818
|
+
return undefined;
|
|
819
|
+
}
|
|
820
|
+
return String(value);
|
|
821
|
+
}
|