{"repo": "annoyingwinter/ozon-pilot-1", "files": {"src/LitchiOzonRecovery/ProductAutomation.cs": {"cccbd16f67e8a45f46488373faf41be810e73a3b": {"t": 1785745824.8311284, "n": 48417, "h": "e13bb642c48b808f", "c": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Net;\nusing System.Text;\nusing System.Threading;\nusing Newtonsoft.Json;\nusing Newtonsoft.Json.Linq;\n\nnamespace LitchiOzonRecovery\n{\n internal sealed class SourcingSeed\n {\n public string Keyword { get; set; }\n public string Category { get; set; }\n public string Why { get; set; }\n }\n\n internal sealed class SourcingOptions\n {\n public string Provider { get; set; }\n public string ApiKey { get; set; }\n public string ApiSecret { get; set; }\n public int PerKeywordLimit { get; set; }\n public int DetailLimit { get; set; }\n public decimal RubPerCny { get; set; }\n public long OzonCategoryId { get; set; }\n public long OzonTypeId { get; set; }\n public decimal PriceMultiplier { get; set; }\n public string CurrencyCode { get; set; }\n public string Vat { get; set; }\n }\n\n internal sealed class SourceProduct\n {\n public string OfferId { get; set; }\n public string Title { get; set; }\n public string RussianTitle { get; set; }\n public string RussianDescription { get; set; }\n public string EnglishKeyword { get; set; }\n public string SourceUrl { get; set; }\n public decimal PriceCny { get; set; }\n public string PriceText { get; set; }\n public int SalesCount { get; set; }\n public string ShopName { get; set; }\n public string ShopUrl { get; set; }\n public string MainImage { get; set; }\n public List Images { get; set; }\n public Dictionary Attributes { get; set; }\n public Dictionary OzonAttributes { get; set; }\n public string Keyword { get; set; }\n public decimal Score { get; set; }\n public string Decision { get; set; }\n public string Reason { get; set; }\n\n public SourceProduct()\n {\n Images = new List();\n Attributes = new Dictionary();\n OzonAttributes = new Dictionary();\n }\n }\n\n internal sealed class SourcingResult\n {\n public List Products { get; private set; }\n public List Logs { get; private set; }\n\n public SourcingResult()\n {\n Products = new List();\n Logs = new List();\n }\n }\n\n internal sealed class OzonImportResult\n {\n public bool Success { get; set; }\n public string TaskId { get; set; }\n public string RawResponse { get; set; }\n public string ErrorMessage { get; set; }\n public string ImportInfoResponse { get; set; }\n public string ImportSummary { get; set; }\n }\n\n internal sealed class ProductAutomationService\n {\n private const string OneboundSearchEndpoint = \"https://api-gw.onebound.cn/1688/item_search/\";\n private const string OneboundDetailEndpoint = \"https://api-gw.onebound.cn/1688/item_get/\";\n private const string DingdanxiaSearchEndpoint = \"https://api.dingdanxia.com/1688/item_search\";\n private const string DingdanxiaDetailEndpoint = \"https://api.dingdanxia.com/1688/item_get\";\n private const string OzonSellerApiBaseUrl = \"https://api-seller.ozon.ru\";\n private const string DeepSeekChatEndpoint = \"https://api.deepseek.com/chat/completions\";\n private const string DeepSeekApiKeyEnvVar = \"DEEPSEEK_API_KEY\";\n private static bool _tlsInitialized;\n private readonly OzonProductImageAdaptationModule _imageAdapter = new OzonProductImageAdaptationModule();\n\n public SourcingResult Collect1688Candidates(IList seeds, AppConfig config, SourcingOptions options)\n {\n SourcingResult result = new SourcingResult();\n if (seeds == null || seeds.Count == 0)\n {\n result.Logs.Add(\"No keyword seeds.\");\n return result;\n }\n\n Validate1688Options(options);\n int perKeywordLimit = options.PerKeywordLimit <= 0 ? 5 : options.PerKeywordLimit;\n int detailLimit = options.DetailLimit <= 0 ? 12 : options.DetailLimit;\n Dictionary byOfferId = new Dictionary();\n\n for (int i = 0; i < seeds.Count; i++)\n {\n SourcingSeed seed = seeds[i];\n if (seed == null || string.IsNullOrEmpty(seed.Keyword))\n {\n continue;\n }\n\n result.Logs.Add(\"Search 1688: \" + seed.Keyword);\n List found = Search1688(seed.Keyword, options, perKeywordLimit);\n result.Logs.Add(\" found \" + found.Count + \" cards\");\n\n for (int j = 0; j < found.Count; j++)\n {\n SourceProduct product = found[j];\n product.Keyword = seed.Keyword;\n if (string.IsNullOrEmpty(product.OfferId) || byOfferId.ContainsKey(product.OfferId))\n {\n continue;\n }\n\n byOfferId[product.OfferId] = product;\n }\n }\n\n List candidates = new List(byOfferId.Values);\n candidates.Sort(delegate(SourceProduct left, SourceProduct right)\n {\n return right.SalesCount.CompareTo(left.SalesCount);\n });\n\n int enrichCount = Math.Min(detailLimit, candidates.Count);\n for (int i = 0; i < enrichCount; i++)\n {\n try\n {\n SourceProduct detail = Get1688Detail(candidates[i].OfferId, options);\n MergeProduct(candidates[i], detail);\n }\n catch (Exception ex)\n {\n result.Logs.Add(\" detail failed \" + candidates[i].OfferId + \": \" + ex.Message);\n }\n }\n\n for (int i = 0; i < candidates.Count; i++)\n {\n ScoreCandidate(candidates[i], config);\n }\n\n candidates.Sort(delegate(SourceProduct left, SourceProduct right)\n {\n int decision = DecisionRank(right.Decision).CompareTo(DecisionRank(left.Decision));\n if (decision != 0) return decision;\n return right.Score.CompareTo(left.Score);\n });\n\n result.Products.AddRange(candidates);\n result.Logs.Add(\"Final candidates: \" + result.Products.Count);\n return result;\n }\n\n public string GenerateEnglishCategoryKeyword(string categoryText)\n {\n if (string.IsNullOrEmpty(categoryText))\n {\n return string.Empty;\n }\n\n string deepSeekApiKey = Environment.GetEnvironmentVariable(DeepSeekApiKeyEnvVar);\n if (string.IsNullOrWhiteSpace(deepSeekApiKey))\n {\n return string.Empty;\n }\n\n try\n {\n JObject request = new JObject();\n request[\"model\"] = \"deepseek-chat\";\n request[\"temperature\"] = 0.1;\n request[\"response_format\"] = new JObject(new JProperty(\"type\", \"json_object\"));\n\n JArray messages = new JArray();\n messages.Add(new JObject(\n new JProperty(\"role\", \"system\"),\n new JProperty(\"content\", \"Return strict JSON only. Convert marketplace category names into one precise English 1688 search keyword. Do not return generic words such as product, item, general merchandise, toy unless the category is actually toys. Keep it 2-5 words.\")));\n messages.Add(new JObject(\n new JProperty(\"role\", \"user\"),\n new JProperty(\"content\", \"Category: \" + categoryText + \"\\nSchema: {\\\"keyword\\\":\\\"precise english keyword\\\"}\")));\n request[\"messages\"] = messages;\n\n JObject response = JObject.Parse(PostDeepSeekJson(request.ToString(Formatting.None), deepSeekApiKey));\n string content = Convert.ToString(response.SelectToken(\"choices[0].message.content\") ?? string.Empty);\n JObject json = JObject.Parse(ExtractJsonObject(content));\n return CleanPublicText(Convert.ToString(json[\"keyword\"] ?? string.Empty));\n }\n catch\n {\n return string.Empty;\n }\n }\n\n public OzonImportResult UploadToOzon(IList products, SourcingOptions options, string clientId, string apiKey)\n {\n return UploadToOzon(products, options, clientId, apiKey, null);\n }\n\n public OzonImportResult UploadToOzon(IList products, SourcingOptions options, string clientId, string apiKey, Action log)\n {\n if (products == null || products.Count == 0)\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"No products selected.\" };\n }\n\n if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(apiKey))\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"Ozon Client-Id and Api-Key are required.\" };\n }\n\n if (options.OzonCategoryId <= 0 || options.OzonTypeId <= 0)\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"Ozon category id and type id are required.\" };\n }\n\n JArray items = new JArray();\n JArray categoryAttributes = LoadOzonCategoryAttributes(options, clientId, apiKey);\n WriteLog(log, \"[upload] selected products after filtering: \" + products.Count + \".\");\n for (int i = 0; i < products.Count; i++)\n {\n SourceProduct product = products[i];\n if (product == null)\n {\n continue;\n }\n\n WriteLog(log, \"[upload] prepare \" + (i + 1) + \"/\" + products.Count + \": \" + SafeOfferId(product.OfferId));\n EnrichProductWithDeepSeek(product, options, categoryAttributes);\n _imageAdapter.AdaptPassedProductImage(product, log);\n items.Add(BuildOzonImportItem(product, options));\n }\n\n JObject payload = new JObject();\n payload[\"items\"] = items;\n string response = PostOzonJson(\"/v3/product/import\", payload.ToString(Formatting.None), clientId, apiKey);\n JObject parsed = JObject.Parse(response);\n string taskId = Convert.ToString(parsed.SelectToken(\"result.task_id\") ?? parsed.SelectToken(\"task_id\") ?? string.Empty);\n return new OzonImportResult\n {\n Success = true,\n TaskId = taskId,\n RawResponse = response\n };\n }\n\n public string GetOzonImportInfo(string taskId, string clientId, string apiKey)\n {\n JObject payload = new JObject();\n payload[\"task_id\"] = taskId;\n return PostOzonJson(\"/v1/product/import/info\", payload.ToString(Formatting.None), clientId, apiKey);\n }\n\n private JArray LoadOzonCategoryAttributes(SourcingOptions options, string clientId, string apiKey)\n {\n try\n {\n if (options == null || options.OzonCategoryId <= 0 || options.OzonTypeId <= 0)\n {\n return new JArray();\n }\n\n JObject payload = new JObject();\n payload[\"description_category_id\"] = options.OzonCategoryId;\n payload[\"type_id\"] = options.OzonTypeId;\n payload[\"language\"] = \"RU\";\n JObject root = JObject.Parse(PostOzonJson(\"/v1/description-category/attribute\", payload.ToString(Formatting.None), clientId, apiKey));\n JArray result = root[\"result\"] as JArray;\n return result ?? new JArray();\n }\n catch\n {\n return new JArray();\n }\n }\n\n public OzonImportResult WaitForOzonImportInfo(string taskId, string clientId, string apiKey, int attempts, int delayMs)\n {\n OzonImportResult result = new OzonImportResult();\n result.TaskId = taskId;\n if (string.IsNullOrEmpty(taskId))\n {\n result.Success = false;\n result.ErrorMessage = \"Ozon returned no task_id.\";\n return result;\n }\n\n int count = attempts <= 0 ? 6 : attempts;\n int wait = delayMs <= 0 ? 5000 : delayMs;\n string lastResponse = string.Empty;\n for (int i = 0; i < count; i++)\n {\n if (i > 0)\n {\n Thread.Sleep(wait);\n }\n\n lastResponse = GetOzonImportInfo(taskId, clientId, apiKey);\n string summary = BuildOzonImportSummary(lastResponse);\n if (!IsImportStillProcessing(summary))\n {\n result.Success = !HasImportErrors(summary);\n result.ImportInfoResponse = lastResponse;\n result.ImportSummary = summary;\n return result;\n }\n }\n\n result.Success = false;\n result.ImportInfoResponse = lastResponse;\n result.ImportSummary = \"Ozon import task is still processing. task_id=\" + taskId + Environment.NewLine +\n BuildOzonImportSummary(lastResponse);\n return result;\n }\n\n public string BuildOzonImportSummary(string response)\n {\n if (string.IsNullOrEmpty(response))\n {\n return \"Empty Ozon import info response.\";\n }\n\n try\n {\n JObject root = JObject.Parse(response);\n StringBuilder builder = new StringBuilder();\n JToken result = root[\"result\"] ?? root;\n string taskStatus = FirstTokenString(result, \"status\", \"state\", \"task_status\");\n if (!string.IsNullOrEmpty(taskStatus))\n {\n builder.AppendLine(\"task status: \" + taskStatus);\n }\n\n JArray items = FindArray(result, \"items\", \"products\", \"errors\");\n if (items == null || items.Count == 0)\n {\n string message = FirstTokenString(root, \"message\", \"error\");\n if (!string.IsNullOrEmpty(message))\n {\n builder.AppendLine(\"message: \" + message);\n }\n\n if (builder.Length == 0)\n {\n builder.AppendLine(\"No item-level result yet; Ozon may still be processing.\");\n }\n\n return builder.ToString().Trim();\n }\n\n int ok = 0;\n int failed = 0;\n for (int i = 0; i < items.Count; i++)\n {\n JObject item = items[i] as JObject;\n if (item == null)\n {\n continue;\n }\n\n string offerId = FirstTokenString(item, \"offer_id\", \"offerId\", \"article\");\n string status = FirstTokenString(item, \"status\", \"state\");\n JArray errors = FindArray(item, \"errors\", \"error\");\n bool hasErrors = errors != null && errors.Count > 0;\n if (hasErrors || status.IndexOf(\"fail\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n status.IndexOf(\"error\", StringComparison.OrdinalIgnoreCase) >= 0)\n {\n failed += 1;\n builder.AppendLine(\"failed: \" + SafeText(offerId) + \" \" + SafeText(status));\n AppendErrorMessages(builder, errors);\n }\n else\n {\n ok += 1;\n builder.AppendLine(\"accepted: \" + SafeText(offerId) + \" \" + SafeText(status));\n }\n }\n\n builder.Insert(0, \"items accepted=\" + ok + \", failed=\" + failed + Environment.NewLine);\n return builder.ToString().Trim();\n }\n catch (Exception ex)\n {\n return \"Could not parse Ozon import info: \" + ex.Message + Environment.NewLine + response;\n }\n }\n\n private static bool HasImportErrors(string summary)\n {\n if (string.IsNullOrEmpty(summary))\n {\n return true;\n }\n\n string lower = summary.ToLowerInvariant();\n if (lower.IndexOf(\"failed=0\", StringComparison.OrdinalIgnoreCase) >= 0 &&\n lower.IndexOf(\"failed:\", StringComparison.OrdinalIgnoreCase) < 0 &&\n lower.IndexOf(\"error:\", StringComparison.OrdinalIgnoreCase) < 0)\n {\n return false;\n }\n\n return lower.IndexOf(\"failed:\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"error:\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"could not parse\", StringComparison.OrdinalIgnoreCase) >= 0;\n }\n\n private static bool IsImportStillProcessing(string summary)\n {\n if (string.IsNullOrEmpty(summary))\n {\n return true;\n }\n\n string lower = summary.ToLowerInvariant();\n return lower.IndexOf(\"processing\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"pending\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"no item-level result yet\", StringComparison.OrdinalIgnoreCase) >= 0;\n }\n\n public void ExportCandidates(string path, IList products)\n {\n JArray array = JArray.FromObject(products ?? new List());\n File.WriteAllText(path, array.ToString(Formatting.Indented), new UTF8Encoding(false));\n }\n\n private static string FirstTokenString(JToken token, params string[] names)\n {\n if (token == null)\n {\n return string.Empty;\n }\n\n for (int i = 0; i < names.Length; i++)\n {\n JToken child = token[names[i]];\n if (child != null && child.Type != JTokenType.Null)\n {\n string text = Convert.ToString(child);\n if (!string.IsNullOrEmpty(text))\n {\n return text.Trim();\n }\n }\n }\n\n return string.Empty;\n }\n\n private static JArray FindArray(JToken token, params string[] names)\n {\n if (token == null)\n {\n return null;\n }\n\n for (int i = 0; i < names.Length; i++)\n {\n JToken direct = token[names[i]];\n if (direct is JArray)\n {\n return (JArray)direct;\n }\n\n if (direct != null)\n {\n JArray nested = FindArray(direct, names);\n if (nested != null)\n {\n return nested;\n }\n }\n }\n\n JObject obj = token as JObject;\n if (obj != null)\n {\n foreach (JProperty property in obj.Properties())\n {\n if (property.Value is JArray)\n {\n for (int i = 0; i < names.Length; i++)\n {\n if (property.Name.IndexOf(names[i], StringComparison.OrdinalIgnoreCase) >= 0)\n {\n return (JArray)property.Value;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n private static void AppendErrorMessages(StringBuilder builder, JArray errors)\n {\n if (builder == null || errors == null)\n {\n return;\n }\n\n for (int i = 0; i < errors.Count; i++)\n {\n JObject error = errors[i] as JObject;\n if (error == null)\n {\n builder.AppendLine(\" error: \" + Convert.ToString(errors[i]));\n continue;\n }\n\n string code = FirstTokenString(error, \"code\", \"field\", \"attribute_id\");\n string message = FirstTokenString(error, \"message\", \"error\", \"text\", \"description\");\n builder.AppendLine(\" error: \" + (string.IsNullOrEmpty(code) ? string.Empty : code + \" - \") + SafeText(message));\n }\n }\n\n private static string SafeText(string value)\n {\n return string.IsNullOrEmpty(value) ? \"(none)\" : value;\n }\n\n private static void Validate1688Options(SourcingOptions options)\n {\n if (options == null)\n {\n throw new ArgumentNullException(\"options\");\n }\n\n if (string.IsNullOrEmpty(options.ApiKey))\n {\n throw new InvalidOperationException(\"1688 API key is required.\");\n }\n }\n\n private static int DecisionRank(string decision)\n {\n if (string.Equals(decision, \"Go\", StringComparison.OrdinalIgnoreCase)) return 3;\n if (string.Equals(decision, \"Watch\", StringComparison.OrdinalIgnoreCase)) return 2;\n return 1;\n }\n\n private static void ScoreCandidate(SourceProduct product, AppConfig config)\n {\n decimal score = 0m;\n List reasons = new List();\n\n if (product.PriceCny > 0)\n {\n score += 20m;\n if (config != null && config.IsFilterGmPrice && product.PriceCny < config.MinGmPirce)\n {\n reasons.Add(\"source price below configured minimum\");\n }\n }\n else\n {\n reasons.Add(\"missing source price\");\n }\n\n if (product.SalesCount > 0)\n {\n score += Math.Min(25m, product.SalesCount / 20m);\n }\n else\n {\n reasons.Add(\"missing sales signal\");\n }\n\n if (!string.IsNullOrEmpty(product.MainImage) || product.Images.Count > 0)\n {\n score += 20m;\n }\n else\n {\n reasons.Add(\"missing product image\");\n }\n\n if (product.Attributes.Count > 0)\n {\n score += Math.Min(15m, product.Attributes.Count * 2m);\n }\n\n if (!string.IsNullOrEmpty(product.ShopName))\n {\n score += 10m;\n }\n\n if (config != null && config.MinSaleNum > 0 && product.SalesCount > 0 && product.SalesCount < config.MinSaleNum)\n {\n reasons.Add(\"sales below configured minimum\");\n }\n\n product.Score = Math.Round(score, 2);\n product.Decision = reasons.Count == 0 && score >= 45m ? \"Go\" : (score >= 30m ? \"Watch\" : \"No-Go\");\n product.Reason = reasons.Count == 0 ? \"meets basic source, sales and content checks\" : string.Join(\"; \", reasons.ToArray());\n }\n\n private static void MergeProduct(SourceProduct target, SourceProduct detail)\n {\n if (target == null || detail == null)\n {\n return;\n }\n\n if (!string.IsNullOrEmpty(detail.Title)) target.Title = detail.Title;\n if (!string.IsNullOrEmpty(detail.SourceUrl)) target.SourceUrl = detail.SourceUrl;\n if (detail.PriceCny > 0) target.PriceCny = detail.PriceCny;\n if (!string.IsNullOrEmpty(detail.PriceText)) target.PriceText = detail.PriceText;\n if (detail.SalesCount > 0) target.SalesCount = detail.SalesCount;\n if (!string.IsNullOrEmpty(detail.ShopName)) target.ShopName = detail.ShopName;\n if (!string.IsNullOrEmpty(detail.ShopUrl)) target.ShopUrl = detail.ShopUrl;\n if (!string.IsNullOrEmpty(detail.MainImage)) target.MainImage = detail.MainImage;\n if (detail.Images.Count > 0) target.Images = detail.Images;\n\n foreach (KeyValuePair pair in detail.Attributes)\n {\n if (!target.Attributes.ContainsKey(pair.Key))\n {\n target.Attributes[pair.Key] = pair.Value;\n }\n }\n }\n\n private static void EnrichProductWithDeepSeek(SourceProduct product, SourcingOptions options, JArray categoryAttributes)\n {\n if (product == null)\n {\n return;\n }\n\n string deepSeekApiKey = Environment.GetEnvironmentVariable(DeepSeekApiKeyEnvVar);\n if (string.IsNullOrWhiteSpace(deepSeekApiKey))\n {\n if (string.IsNullOrEmpty(product.RussianTitle))\n {\n product.RussianTitle = Truncate(CleanPublicText(product.Title), 120);\n }\n\n return;\n }\n\n try\n {\n JObject source = new JObject();\n source[\"title_cn\"] = product.Title ?? string.Empty;\n source[\"price_cny\"] = product.PriceCny;\n source[\"source_url\"] = product.SourceUrl ?? string.Empty;\n source[\"keyword\"] = product.Keyword ?? product.EnglishKeyword ?? string.Empty;\n source[\"attributes_cn\"] = JObject.FromObject(product.Attributes ?? new Dictionary());\n source[\"ozon_required_attributes\"] = BuildDeepSeekAttributeHints(categoryAttributes);\n\n JObject request = new JObject();\n request[\"model\"] = \"deepseek-chat\";\n request[\"temperature\"] = 0.2;\n request[\"response_format\"] = new JObject(new JProperty(\"type\", \"json_object\"));\n JArray messages = new JArray();\n messages.Add(new JObject(\n new JProperty(\"role\", \"system\"),\n new JProperty(\"content\", \"You generate Ozon marketplace listings. Return strict JSON only. Russian title and description are mandatory. Search keyword must be precise English. Fill Ozon required attributes when a text value can be inferred; do not invent safety certifications.\")));\n messages.Add(new JObject(\n new JProperty(\"role\", \"user\"),\n new JProperty(\"content\",\n \"Create listing JSON for this 1688 product. Required schema: {\\\"search_keyword_en\\\":\\\"...\\\",\\\"title_ru\\\":\\\"...\\\",\\\"description_ru\\\":\\\"...\\\",\\\"ozon_attributes\\\":[{\\\"id\\\":123,\\\"value\\\":\\\"...\\\"}],\\\"dimensions\\\":{\\\"height_mm\\\":100,\\\"width_mm\\\":100,\\\"depth_mm\\\":100,\\\"weight_g\\\":500}}. Product data: \" +\n source.ToString(Formatting.None))));\n request[\"messages\"] = messages;\n\n JObject response = JObject.Parse(PostDeepSeekJson(request.ToString(Formatting.None), deepSeekApiKey));\n string content = Convert.ToString(response.SelectToken(\"choices[0].message.content\") ?? string.Empty);\n JObject listing = JObject.Parse(ExtractJsonObject(content));\n product.EnglishKeyword = CleanPublicText(Convert.ToString(listing[\"search_keyword_en\"] ?? string.Empty));\n product.RussianTitle = CleanPublicText(Convert.ToString(listing[\"title_ru\"] ?? string.Empty));\n product.RussianDescription = CleanPublicText(Convert.ToString(listing[\"description_ru\"] ?? string.Empty));\n\n JArray attrs = listing[\"ozon_attributes\"] as JArray;\n for (int i = 0; attrs != null && i < attrs.Count; i++)\n {\n JObject attr = attrs[i] as JObject;\n if (attr == null)\n {\n continue;\n }\n\n long id = 0;\n long.TryParse(Convert.ToString(attr[\"id\"] ?? string.Empty), out id);\n string value = CleanPublicText(Convert.ToString(attr[\"value\"] ?? string.Empty));\n if (id > 0 && !string.IsNullOrEmpty(value) && !product.OzonAttributes.ContainsKey(id))\n {\n product.OzonAttributes[id] = value;\n }\n }\n\n JObject dimensions = listing[\"dimensions\"] as JObject;\n if (dimensions != null)\n {\n AddDimensionAttribute(product, \"height_mm\", Convert.ToString(dimensions[\"height_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"width_mm\", Convert.ToString(dimensions[\"width_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"depth_mm\", Convert.ToString(dimensions[\"depth_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"weight_g\", Convert.ToString(dimensions[\"weight_g\"] ?? string.Empty));\n }\n }\n catch\n {\n if (string.IsNullOrEmpty(product.RussianTitle))\n {\n product.RussianTitle = Truncate(CleanPublicText(product.Title), 120);\n }\n }\n }\n\n private static JArray BuildDeepSeekAttributeHints(JArray attributes)\n {\n JArray hints = new JArray();\n for (int i = 0; attributes != null && i < attributes.Count && hints.Count < 30; i++)\n {\n JObject attr = attributes[i] as JObject;\n if (attr == null)\n {\n continue;\n }\n\n bool required = string.Equals(Convert.ToString(attr[\"is_required\"] ?? string.Empty), \"True\", StringComparison.OrdinalIgnoreCase) ||\n string.Equals(Convert.ToString(attr[\"required\"] ?? string.Empty), \"True\", StringComparison.OrdinalIgnoreCase);\n if (!required)\n {\n continue;\n }\n\n JObject hint = new JObject();\n hint[\"id\"] = attr[\"id\"];\n hint[\"name\"] = Convert.ToString(attr[\"name\"] ?? attr[\"attribute_name\"] ?? string.Empty);\n hint[\"type\"] = Convert.ToString(attr[\"type\"] ?? string.Empty);\n hint[\"dictionary_id\"] = attr[\"dictionary_id\"];\n hints.Add(hint);\n }\n\n return hints;\n }\n\n private static void AddDimensionAttribute(SourceProduct product, string name, string value)\n {\n if (product == null || string.IsNullOrEmpty(value))\n {\n return;\n }\n\n product.Attributes[name] = value;\n }\n\n private static List Search1688(string keyword, SourcingOptions options, int limit)\n {\n string provider = NormalizeProvider(options.Provider);\n string url = provider == \"dingdanxia\"\n ? DingdanxiaSearchEndpoint + \"?apikey=\" + Uri.EscapeDataString(options.ApiKey) + \"&q=\" + Uri.EscapeDataString(keyword) + \"&page=1\"\n : OneboundSearchEndpoint + \"?key=\" + Uri.EscapeDataString(options.ApiKey) + \"&secret=\" + Uri.EscapeDataString(options.ApiSecret ?? string.Empty) + \"&q=\" + Uri.EscapeDataString(keyword) + \"&page=1&sort=default&page_size=\" + limit;\n JObject json = JObject.Parse(GetJson(url));\n JArray items = FindFirstArray(json, new string[] { \"items.item\", \"result\", \"data\", \"items\" });\n List products = new List();\n\n if (items == null)\n {\n return products;\n }\n\n for (int i = 0; i < items.Count && products.Count < limit; i++)\n {\n JObject item = items[i] as JObject;\n if (item == null)\n {\n continue;\n }\n\n string offerId = FirstString(item, \"num_iid\", \"id\", \"offerId\", \"offer_id\");\n if (string.IsNullOrEmpty(offerId))\n {\n continue;\n }\n\n SourceProduct product = new SourceProduct();\n product.OfferId = offerId;\n product.Title = FirstString(item, \"title\", \"name\");\n product.SourceUrl = \"https://detail.1688.com/offer/\" + offerId + \".html\";\n product.PriceText = FirstString(item, \"price\", \"salePrice\", \"priceText\");\n product.PriceCny = ParseMoney(product.PriceText);\n product.MainImage = FirstString(item, \"pic_url\", \"image\", \"img\", \"picUrl\");\n if (!string.IsNullOrEmpty(product.MainImage)) product.Images.Add(product.MainImage);\n product.ShopName = FirstString(item, \"nick\", \"shop_name\", \"seller_nick\");\n product.ShopUrl = FirstString(item, \"seller_url\", \"shop_url\");\n product.SalesCount = ParseInt(FirstString(item, \"sales\", \"volume\", \"sold\", \"sale_count\"));\n products.Add(product);\n }\n\n return products;\n }\n\n private static SourceProduct Get1688Detail(string offerId, SourcingOptions options)\n {\n string provider = NormalizeProvider(options.Provider);\n string url = provider == \"dingdanxia\"\n ? DingdanxiaDetailEndpoint + \"?apikey=\" + Uri.EscapeDataString(options.ApiKey) + \"&num_iid=\" + Uri.EscapeDataString(offerId)\n : OneboundDetailEndpoint + \"?key=\" + Uri.EscapeDataString(options.ApiKey) + \"&secret=\" + Uri.EscapeDataString(options.ApiSecret ?? string.Empty) + \"&num_iid=\" + Uri.EscapeDataString(offerId) + \"&lang=zh-CN\";\n JObject json = JObject.Parse(GetJson(url));\n JObject item = FirstObject(json, \"item\", \"result\", \"data\");\n if (item == null)\n {\n return null;\n }\n\n SourceProduct product = new SourceProduct();\n product.OfferId = offerId;\n product.Title = FirstString(item, \"title\", \"name\");\n product.SourceUrl = \"https://detail.1688.com/offer/\" + offerId + \".html\";\n product.PriceText = FirstString(item, \"price\", \"orginal_price\", \"salePrice\");\n product.PriceCny = ParseMoney(product.PriceText);\n product.ShopName = FirstString(item, \"nick\", \"seller_nick\", \"shop_name\");\n product.ShopUrl = FirstString(item, \"seller_url\", \"shop_url\");\n product.SalesCount = ParseInt(FirstString(item, \"sales\", \"volume\", \"sold\", \"sale_count\"));\n\n AddImage(product, FirstString(item, \"pic_url\", \"image\", \"main_image\"));\n JArray itemImages = FindFirstArray(item, new string[] { \"item_imgs.item_img\", \"images\", \"image_urls\" });\n if (itemImages != null)\n {\n for (int i = 0; i < itemImages.Count; i++)\n {\n JObject imageObject = itemImages[i] as JObject;\n string image = imageObject == null ? Convert.ToString(itemImages[i]) : FirstString(imageObject, \"url\", \"image\", \"img\");\n AddImage(product, image);\n }\n }\n\n JObject props = item[\"props_list\"] as JObject;\n if (props != null)\n {\n foreach (JProperty property in props.Properties())\n {\n string key = property.Name;\n int colon = key.IndexOf(':');\n if (colon >= 0 && colon + 1 < key.Length)\n {\n key = key.Substring(colon + 1);\n }\n\n if (!product.Attributes.ContainsKey(key))\n {\n product.Attributes[key] = Convert.ToString(property.Value);\n }\n }\n }\n\n return product;\n }\n\n private static JObject BuildOzonImportItem(SourceProduct product, SourcingOptions options)\n {\n decimal multiplier = options.PriceMultiplier <= 0 ? 2.2m : options.PriceMultiplier;\n string currency = string.IsNullOrEmpty(options.CurrencyCode) ? \"CNY\" : options.CurrencyCode;\n decimal price = string.Equals(currency, \"RUB\", StringComparison.OrdinalIgnoreCase)\n ? Math.Ceiling(Math.Max(1m, product.PriceCny) * (options.RubPerCny <= 0 ? 12.5m : options.RubPerCny) * multiplier)\n : Math.Ceiling(Math.Max(1m, product.PriceCny) * multiplier);\n string offerId = \"LZ1688-\" + SafeOfferId(product.OfferId);\n string primaryImage = !string.IsNullOrEmpty(product.MainImage) ? product.MainImage : (product.Images.Count > 0 ? product.Images[0] : string.Empty);\n string title = !string.IsNullOrEmpty(product.RussianTitle) ? product.RussianTitle : product.Title;\n string description = !string.IsNullOrEmpty(product.RussianDescription)\n ? product.RussianDescription\n : CleanPublicText(product.Title) + \"\\nИсточник: \" + product.SourceUrl;\n\n JObject item = new JObject();\n item[\"description_category_id\"] = options.OzonCategoryId;\n item[\"type_id\"] = options.OzonTypeId;\n item[\"name\"] = Truncate(CleanPublicText(title), 500);\n item[\"offer_id\"] = offerId;\n item[\"barcode\"] = string.Empty;\n item[\"price\"] = price.ToString(\"0\");\n item[\"old_price\"] = Math.Ceiling(price * 1.25m).ToString(\"0\");\n item[\"currency_code\"] = currency;\n item[\"vat\"] = string.IsNullOrEmpty(options.Vat) ? \"0\" : options.Vat;\n item[\"height\"] = 100;\n item[\"depth\"] = 100;\n item[\"width\"] = 100;\n item[\"dimension_unit\"] = \"mm\";\n item[\"weight\"] = 500;\n item[\"weight_unit\"] = \"g\";\n item[\"primary_image\"] = primaryImage;\n\n JArray images = new JArray();\n for (int i = 0; i < product.Images.Count; i++)\n {\n if (product.Images[i] != primaryImage)\n {\n images.Add(product.Images[i]);\n }\n }\n item[\"images\"] = images;\n\n JArray attributes = new JArray();\n foreach (KeyValuePair pair in product.OzonAttributes)\n {\n if (pair.Key <= 0 || string.IsNullOrEmpty(pair.Value))\n {\n continue;\n }\n\n JObject attr = new JObject();\n attr[\"id\"] = pair.Key;\n JArray values = new JArray();\n values.Add(new JObject(new JProperty(\"value\", Truncate(CleanPublicText(pair.Value), 900))));\n attr[\"values\"] = values;\n attributes.Add(attr);\n }\n\n item[\"attributes\"] = attributes;\n item[\"description\"] = Truncate(CleanPublicText(description), 3000);\n return item;\n }\n\n private static string PostOzonJson(string path, string json, string clientId, string apiKey)\n {\n EnsureModernTls();\n string url = OzonSellerApiBaseUrl + path;\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);\n request.Method = \"POST\";\n request.ContentType = \"application/json\";\n request.Accept = \"application/json\";\n request.Timeout = 60000;\n request.Headers[\"Client-Id\"] = clientId;\n request.Headers[\"Api-Key\"] = apiKey;\n\n byte[] body = Encoding.UTF8.GetBytes(json);\n request.ContentLength = body.Length;\n using (Stream requestStream = request.GetRequestStream())\n {\n requestStream.Write(body, 0, body.Length);\n }\n\n return ReadResponse(request);\n }\n\n private static string PostDeepSeekJson(string json, string apiKey)\n {\n EnsureModernTls();\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(DeepSeekChatEndpoint);\n request.Method = \"POST\";\n request.ContentType = \"application/json\";\n request.Accept = \"application/json\";\n request.Timeout = 45000;\n request.Headers[\"Authorization\"] = \"Bearer \" + apiKey;\n\n byte[] body = Encoding.UTF8.GetBytes(json);\n request.ContentLength = body.Length;\n using (Stream requestStream = request.GetRequestStream())\n {\n requestStream.Write(body, 0, body.Length);\n }\n\n return ReadResponse(request);\n }\n\n private static string GetJson(string url)\n {\n EnsureModernTls();\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);\n request.Method = \"GET\";\n request.Accept = \"application/json\";\n request.UserAgent = \"OZON-PILOT/1.0\";\n request.Timeout = 45000;\n return ReadResponse(request);\n }\n\n private static void EnsureModernTls()\n {\n if (_tlsInitialized)\n {\n return;\n }\n\n // TLS 1.2 is required by Ozon Seller API. .NET 4.0 may not expose the enum names,\n // so use numeric values while still allowing older endpoints used by legacy suppliers.\n const int tls10 = 192;\n const int tls11 = 768;\n const int tls12 = 3072;\n ServicePointManager.SecurityProtocol =\n (SecurityProtocolType)(tls10 | tls11 | tls12);\n ServicePointManager.Expect100Continue = false;\n ServicePointManager.ServerCertificateValidationCallback =\n delegate { return true; };\n _tlsInitialized = true;\n }\n\n private static string ReadResponse(HttpWebRequest request)\n {\n try\n {\n using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())\n using (Stream stream = response.GetResponseStream())\n using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))\n {\n return reader.ReadToEnd();\n }\n }\n catch (WebException ex)\n {\n string detail = ex.Message;\n if (ex.Response != null)\n {\n using (Stream stream = ex.Response.GetResponseStream())\n using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))\n {\n detail = reader.ReadToEnd();\n }\n }\n\n throw new InvalidOperationException(detail, ex);\n }\n }\n\n private static string NormalizeProvider(string value)\n {\n string provider = string.IsNullOrEmpty(value) ? \"onebound\" : value.Trim().ToLowerInvariant();\n return provider == \"dingdanxia\" ? \"dingdanxia\" : \"onebound\";\n }\n\n private static void AddImage(SourceProduct product, string image)\n {\n if (string.IsNullOrEmpty(image))\n {\n return;\n }\n\n if (string.IsNullOrEmpty(product.MainImage))\n {\n product.MainImage = image;\n }\n\n if (!product.Images.Contains(image))\n {\n product.Images.Add(image);\n }\n }\n\n private static JArray FindFirstArray(JObject obj, string[] paths)\n {\n for (int i = 0; i < paths.Length; i++)\n {\n JToken token = obj.SelectToken(paths[i]);\n if (token is JArray)\n {\n return (JArray)token;\n }\n }\n\n return null;\n }\n\n private static JObject FirstObject(JObject obj, params string[] names)\n {\n for (int i = 0; i < names.Length; i++)\n {\n JObject child = obj[names[i]] as JObject;\n if (child != null)\n {\n return child;\n }\n }\n\n return null;\n }\n\n private static string FirstString(JObject obj, params string[] names)\n {\n for (int i = 0; i < names.Length; i++)\n {\n JToken token = obj[names[i]];\n if (token != null && token.Type != JTokenType.Null)\n {\n string text = Convert.ToString(token);\n if (!string.IsNullOrEmpty(text))\n {\n return text.Trim();\n }\n }\n }\n\n return string.Empty;\n }\n\n private static decimal ParseMoney(string text)\n {\n if (string.IsNullOrEmpty(text))\n {\n return 0m;\n }\n\n StringBuilder builder = new StringBuilder();\n bool dot = false;\n for (int i = 0; i < text.Length; i++)\n {\n char c = text[i];\n if (char.IsDigit(c))\n {\n builder.Append(c);\n }\n else if (c == '.' && !dot)\n {\n builder.Append(c);\n dot = true;\n }\n else if (builder.Length > 0)\n {\n break;\n }\n }\n\n decimal value;\n return decimal.TryParse(builder.ToString(), out value) ? value : 0m;\n }\n\n private static int ParseInt(string text)\n {\n int value;\n return int.TryParse(text, out value) ? value : 0;\n }\n\n private static string SafeOfferId(string value)\n {\n if (string.IsNullOrEmpty(value))\n {\n return Guid.NewGuid().ToString(\"N\").Substring(0, 12);\n }\n\n StringBuilder builder = new StringBuilder();\n for (int i = 0; i < value.Length; i++)\n {\n char c = value[i];\n if (char.IsLetterOrDigit(c) || c == '-' || c == '_')\n {\n builder.Append(c);\n }\n }\n\n return builder.Length == 0 ? Guid.NewGuid().ToString(\"N\").Substring(0, 12) : builder.ToString();\n }\n\n private static void WriteLog(Action log, string message)\n {\n if (log != null)\n {\n log(message);\n }\n }\n\n private static string CleanPublicText(string value)\n {\n string text = value ?? string.Empty;\n text = text.Replace(\"Ozon hot\", string.Empty).Replace(\"1688 hot\", string.Empty);\n return WebUtility.HtmlDecode(text).Replace(\"\\r\", \" \").Replace(\"\\n\", \" \").Replace(\"\\t\", \" \").Trim();\n }\n\n private static string ExtractJsonObject(string value)\n {\n if (string.IsNullOrEmpty(value))\n {\n return \"{}\";\n }\n\n int start = value.IndexOf('{');\n int end = value.LastIndexOf('}');\n if (start >= 0 && end > start)\n {\n return value.Substring(start, end - start + 1);\n }\n\n return value;\n }\n\n private static string Truncate(string value, int maxLength)\n {\n if (string.IsNullOrEmpty(value) || value.Length <= maxLength)\n {\n return value ?? string.Empty;\n }\n\n return value.Substring(0, maxLength);\n }\n }\n}\n"}, "cfde76d9d681e3d460e5a943e8ad51f07c4330bc": {"t": 1785756820.1920621, "n": 48417, "h": "e13bb642c48b808f", "c": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Net;\nusing System.Text;\nusing System.Threading;\nusing Newtonsoft.Json;\nusing Newtonsoft.Json.Linq;\n\nnamespace LitchiOzonRecovery\n{\n internal sealed class SourcingSeed\n {\n public string Keyword { get; set; }\n public string Category { get; set; }\n public string Why { get; set; }\n }\n\n internal sealed class SourcingOptions\n {\n public string Provider { get; set; }\n public string ApiKey { get; set; }\n public string ApiSecret { get; set; }\n public int PerKeywordLimit { get; set; }\n public int DetailLimit { get; set; }\n public decimal RubPerCny { get; set; }\n public long OzonCategoryId { get; set; }\n public long OzonTypeId { get; set; }\n public decimal PriceMultiplier { get; set; }\n public string CurrencyCode { get; set; }\n public string Vat { get; set; }\n }\n\n internal sealed class SourceProduct\n {\n public string OfferId { get; set; }\n public string Title { get; set; }\n public string RussianTitle { get; set; }\n public string RussianDescription { get; set; }\n public string EnglishKeyword { get; set; }\n public string SourceUrl { get; set; }\n public decimal PriceCny { get; set; }\n public string PriceText { get; set; }\n public int SalesCount { get; set; }\n public string ShopName { get; set; }\n public string ShopUrl { get; set; }\n public string MainImage { get; set; }\n public List Images { get; set; }\n public Dictionary Attributes { get; set; }\n public Dictionary OzonAttributes { get; set; }\n public string Keyword { get; set; }\n public decimal Score { get; set; }\n public string Decision { get; set; }\n public string Reason { get; set; }\n\n public SourceProduct()\n {\n Images = new List();\n Attributes = new Dictionary();\n OzonAttributes = new Dictionary();\n }\n }\n\n internal sealed class SourcingResult\n {\n public List Products { get; private set; }\n public List Logs { get; private set; }\n\n public SourcingResult()\n {\n Products = new List();\n Logs = new List();\n }\n }\n\n internal sealed class OzonImportResult\n {\n public bool Success { get; set; }\n public string TaskId { get; set; }\n public string RawResponse { get; set; }\n public string ErrorMessage { get; set; }\n public string ImportInfoResponse { get; set; }\n public string ImportSummary { get; set; }\n }\n\n internal sealed class ProductAutomationService\n {\n private const string OneboundSearchEndpoint = \"https://api-gw.onebound.cn/1688/item_search/\";\n private const string OneboundDetailEndpoint = \"https://api-gw.onebound.cn/1688/item_get/\";\n private const string DingdanxiaSearchEndpoint = \"https://api.dingdanxia.com/1688/item_search\";\n private const string DingdanxiaDetailEndpoint = \"https://api.dingdanxia.com/1688/item_get\";\n private const string OzonSellerApiBaseUrl = \"https://api-seller.ozon.ru\";\n private const string DeepSeekChatEndpoint = \"https://api.deepseek.com/chat/completions\";\n private const string DeepSeekApiKeyEnvVar = \"DEEPSEEK_API_KEY\";\n private static bool _tlsInitialized;\n private readonly OzonProductImageAdaptationModule _imageAdapter = new OzonProductImageAdaptationModule();\n\n public SourcingResult Collect1688Candidates(IList seeds, AppConfig config, SourcingOptions options)\n {\n SourcingResult result = new SourcingResult();\n if (seeds == null || seeds.Count == 0)\n {\n result.Logs.Add(\"No keyword seeds.\");\n return result;\n }\n\n Validate1688Options(options);\n int perKeywordLimit = options.PerKeywordLimit <= 0 ? 5 : options.PerKeywordLimit;\n int detailLimit = options.DetailLimit <= 0 ? 12 : options.DetailLimit;\n Dictionary byOfferId = new Dictionary();\n\n for (int i = 0; i < seeds.Count; i++)\n {\n SourcingSeed seed = seeds[i];\n if (seed == null || string.IsNullOrEmpty(seed.Keyword))\n {\n continue;\n }\n\n result.Logs.Add(\"Search 1688: \" + seed.Keyword);\n List found = Search1688(seed.Keyword, options, perKeywordLimit);\n result.Logs.Add(\" found \" + found.Count + \" cards\");\n\n for (int j = 0; j < found.Count; j++)\n {\n SourceProduct product = found[j];\n product.Keyword = seed.Keyword;\n if (string.IsNullOrEmpty(product.OfferId) || byOfferId.ContainsKey(product.OfferId))\n {\n continue;\n }\n\n byOfferId[product.OfferId] = product;\n }\n }\n\n List candidates = new List(byOfferId.Values);\n candidates.Sort(delegate(SourceProduct left, SourceProduct right)\n {\n return right.SalesCount.CompareTo(left.SalesCount);\n });\n\n int enrichCount = Math.Min(detailLimit, candidates.Count);\n for (int i = 0; i < enrichCount; i++)\n {\n try\n {\n SourceProduct detail = Get1688Detail(candidates[i].OfferId, options);\n MergeProduct(candidates[i], detail);\n }\n catch (Exception ex)\n {\n result.Logs.Add(\" detail failed \" + candidates[i].OfferId + \": \" + ex.Message);\n }\n }\n\n for (int i = 0; i < candidates.Count; i++)\n {\n ScoreCandidate(candidates[i], config);\n }\n\n candidates.Sort(delegate(SourceProduct left, SourceProduct right)\n {\n int decision = DecisionRank(right.Decision).CompareTo(DecisionRank(left.Decision));\n if (decision != 0) return decision;\n return right.Score.CompareTo(left.Score);\n });\n\n result.Products.AddRange(candidates);\n result.Logs.Add(\"Final candidates: \" + result.Products.Count);\n return result;\n }\n\n public string GenerateEnglishCategoryKeyword(string categoryText)\n {\n if (string.IsNullOrEmpty(categoryText))\n {\n return string.Empty;\n }\n\n string deepSeekApiKey = Environment.GetEnvironmentVariable(DeepSeekApiKeyEnvVar);\n if (string.IsNullOrWhiteSpace(deepSeekApiKey))\n {\n return string.Empty;\n }\n\n try\n {\n JObject request = new JObject();\n request[\"model\"] = \"deepseek-chat\";\n request[\"temperature\"] = 0.1;\n request[\"response_format\"] = new JObject(new JProperty(\"type\", \"json_object\"));\n\n JArray messages = new JArray();\n messages.Add(new JObject(\n new JProperty(\"role\", \"system\"),\n new JProperty(\"content\", \"Return strict JSON only. Convert marketplace category names into one precise English 1688 search keyword. Do not return generic words such as product, item, general merchandise, toy unless the category is actually toys. Keep it 2-5 words.\")));\n messages.Add(new JObject(\n new JProperty(\"role\", \"user\"),\n new JProperty(\"content\", \"Category: \" + categoryText + \"\\nSchema: {\\\"keyword\\\":\\\"precise english keyword\\\"}\")));\n request[\"messages\"] = messages;\n\n JObject response = JObject.Parse(PostDeepSeekJson(request.ToString(Formatting.None), deepSeekApiKey));\n string content = Convert.ToString(response.SelectToken(\"choices[0].message.content\") ?? string.Empty);\n JObject json = JObject.Parse(ExtractJsonObject(content));\n return CleanPublicText(Convert.ToString(json[\"keyword\"] ?? string.Empty));\n }\n catch\n {\n return string.Empty;\n }\n }\n\n public OzonImportResult UploadToOzon(IList products, SourcingOptions options, string clientId, string apiKey)\n {\n return UploadToOzon(products, options, clientId, apiKey, null);\n }\n\n public OzonImportResult UploadToOzon(IList products, SourcingOptions options, string clientId, string apiKey, Action log)\n {\n if (products == null || products.Count == 0)\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"No products selected.\" };\n }\n\n if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(apiKey))\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"Ozon Client-Id and Api-Key are required.\" };\n }\n\n if (options.OzonCategoryId <= 0 || options.OzonTypeId <= 0)\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"Ozon category id and type id are required.\" };\n }\n\n JArray items = new JArray();\n JArray categoryAttributes = LoadOzonCategoryAttributes(options, clientId, apiKey);\n WriteLog(log, \"[upload] selected products after filtering: \" + products.Count + \".\");\n for (int i = 0; i < products.Count; i++)\n {\n SourceProduct product = products[i];\n if (product == null)\n {\n continue;\n }\n\n WriteLog(log, \"[upload] prepare \" + (i + 1) + \"/\" + products.Count + \": \" + SafeOfferId(product.OfferId));\n EnrichProductWithDeepSeek(product, options, categoryAttributes);\n _imageAdapter.AdaptPassedProductImage(product, log);\n items.Add(BuildOzonImportItem(product, options));\n }\n\n JObject payload = new JObject();\n payload[\"items\"] = items;\n string response = PostOzonJson(\"/v3/product/import\", payload.ToString(Formatting.None), clientId, apiKey);\n JObject parsed = JObject.Parse(response);\n string taskId = Convert.ToString(parsed.SelectToken(\"result.task_id\") ?? parsed.SelectToken(\"task_id\") ?? string.Empty);\n return new OzonImportResult\n {\n Success = true,\n TaskId = taskId,\n RawResponse = response\n };\n }\n\n public string GetOzonImportInfo(string taskId, string clientId, string apiKey)\n {\n JObject payload = new JObject();\n payload[\"task_id\"] = taskId;\n return PostOzonJson(\"/v1/product/import/info\", payload.ToString(Formatting.None), clientId, apiKey);\n }\n\n private JArray LoadOzonCategoryAttributes(SourcingOptions options, string clientId, string apiKey)\n {\n try\n {\n if (options == null || options.OzonCategoryId <= 0 || options.OzonTypeId <= 0)\n {\n return new JArray();\n }\n\n JObject payload = new JObject();\n payload[\"description_category_id\"] = options.OzonCategoryId;\n payload[\"type_id\"] = options.OzonTypeId;\n payload[\"language\"] = \"RU\";\n JObject root = JObject.Parse(PostOzonJson(\"/v1/description-category/attribute\", payload.ToString(Formatting.None), clientId, apiKey));\n JArray result = root[\"result\"] as JArray;\n return result ?? new JArray();\n }\n catch\n {\n return new JArray();\n }\n }\n\n public OzonImportResult WaitForOzonImportInfo(string taskId, string clientId, string apiKey, int attempts, int delayMs)\n {\n OzonImportResult result = new OzonImportResult();\n result.TaskId = taskId;\n if (string.IsNullOrEmpty(taskId))\n {\n result.Success = false;\n result.ErrorMessage = \"Ozon returned no task_id.\";\n return result;\n }\n\n int count = attempts <= 0 ? 6 : attempts;\n int wait = delayMs <= 0 ? 5000 : delayMs;\n string lastResponse = string.Empty;\n for (int i = 0; i < count; i++)\n {\n if (i > 0)\n {\n Thread.Sleep(wait);\n }\n\n lastResponse = GetOzonImportInfo(taskId, clientId, apiKey);\n string summary = BuildOzonImportSummary(lastResponse);\n if (!IsImportStillProcessing(summary))\n {\n result.Success = !HasImportErrors(summary);\n result.ImportInfoResponse = lastResponse;\n result.ImportSummary = summary;\n return result;\n }\n }\n\n result.Success = false;\n result.ImportInfoResponse = lastResponse;\n result.ImportSummary = \"Ozon import task is still processing. task_id=\" + taskId + Environment.NewLine +\n BuildOzonImportSummary(lastResponse);\n return result;\n }\n\n public string BuildOzonImportSummary(string response)\n {\n if (string.IsNullOrEmpty(response))\n {\n return \"Empty Ozon import info response.\";\n }\n\n try\n {\n JObject root = JObject.Parse(response);\n StringBuilder builder = new StringBuilder();\n JToken result = root[\"result\"] ?? root;\n string taskStatus = FirstTokenString(result, \"status\", \"state\", \"task_status\");\n if (!string.IsNullOrEmpty(taskStatus))\n {\n builder.AppendLine(\"task status: \" + taskStatus);\n }\n\n JArray items = FindArray(result, \"items\", \"products\", \"errors\");\n if (items == null || items.Count == 0)\n {\n string message = FirstTokenString(root, \"message\", \"error\");\n if (!string.IsNullOrEmpty(message))\n {\n builder.AppendLine(\"message: \" + message);\n }\n\n if (builder.Length == 0)\n {\n builder.AppendLine(\"No item-level result yet; Ozon may still be processing.\");\n }\n\n return builder.ToString().Trim();\n }\n\n int ok = 0;\n int failed = 0;\n for (int i = 0; i < items.Count; i++)\n {\n JObject item = items[i] as JObject;\n if (item == null)\n {\n continue;\n }\n\n string offerId = FirstTokenString(item, \"offer_id\", \"offerId\", \"article\");\n string status = FirstTokenString(item, \"status\", \"state\");\n JArray errors = FindArray(item, \"errors\", \"error\");\n bool hasErrors = errors != null && errors.Count > 0;\n if (hasErrors || status.IndexOf(\"fail\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n status.IndexOf(\"error\", StringComparison.OrdinalIgnoreCase) >= 0)\n {\n failed += 1;\n builder.AppendLine(\"failed: \" + SafeText(offerId) + \" \" + SafeText(status));\n AppendErrorMessages(builder, errors);\n }\n else\n {\n ok += 1;\n builder.AppendLine(\"accepted: \" + SafeText(offerId) + \" \" + SafeText(status));\n }\n }\n\n builder.Insert(0, \"items accepted=\" + ok + \", failed=\" + failed + Environment.NewLine);\n return builder.ToString().Trim();\n }\n catch (Exception ex)\n {\n return \"Could not parse Ozon import info: \" + ex.Message + Environment.NewLine + response;\n }\n }\n\n private static bool HasImportErrors(string summary)\n {\n if (string.IsNullOrEmpty(summary))\n {\n return true;\n }\n\n string lower = summary.ToLowerInvariant();\n if (lower.IndexOf(\"failed=0\", StringComparison.OrdinalIgnoreCase) >= 0 &&\n lower.IndexOf(\"failed:\", StringComparison.OrdinalIgnoreCase) < 0 &&\n lower.IndexOf(\"error:\", StringComparison.OrdinalIgnoreCase) < 0)\n {\n return false;\n }\n\n return lower.IndexOf(\"failed:\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"error:\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"could not parse\", StringComparison.OrdinalIgnoreCase) >= 0;\n }\n\n private static bool IsImportStillProcessing(string summary)\n {\n if (string.IsNullOrEmpty(summary))\n {\n return true;\n }\n\n string lower = summary.ToLowerInvariant();\n return lower.IndexOf(\"processing\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"pending\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"no item-level result yet\", StringComparison.OrdinalIgnoreCase) >= 0;\n }\n\n public void ExportCandidates(string path, IList products)\n {\n JArray array = JArray.FromObject(products ?? new List());\n File.WriteAllText(path, array.ToString(Formatting.Indented), new UTF8Encoding(false));\n }\n\n private static string FirstTokenString(JToken token, params string[] names)\n {\n if (token == null)\n {\n return string.Empty;\n }\n\n for (int i = 0; i < names.Length; i++)\n {\n JToken child = token[names[i]];\n if (child != null && child.Type != JTokenType.Null)\n {\n string text = Convert.ToString(child);\n if (!string.IsNullOrEmpty(text))\n {\n return text.Trim();\n }\n }\n }\n\n return string.Empty;\n }\n\n private static JArray FindArray(JToken token, params string[] names)\n {\n if (token == null)\n {\n return null;\n }\n\n for (int i = 0; i < names.Length; i++)\n {\n JToken direct = token[names[i]];\n if (direct is JArray)\n {\n return (JArray)direct;\n }\n\n if (direct != null)\n {\n JArray nested = FindArray(direct, names);\n if (nested != null)\n {\n return nested;\n }\n }\n }\n\n JObject obj = token as JObject;\n if (obj != null)\n {\n foreach (JProperty property in obj.Properties())\n {\n if (property.Value is JArray)\n {\n for (int i = 0; i < names.Length; i++)\n {\n if (property.Name.IndexOf(names[i], StringComparison.OrdinalIgnoreCase) >= 0)\n {\n return (JArray)property.Value;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n private static void AppendErrorMessages(StringBuilder builder, JArray errors)\n {\n if (builder == null || errors == null)\n {\n return;\n }\n\n for (int i = 0; i < errors.Count; i++)\n {\n JObject error = errors[i] as JObject;\n if (error == null)\n {\n builder.AppendLine(\" error: \" + Convert.ToString(errors[i]));\n continue;\n }\n\n string code = FirstTokenString(error, \"code\", \"field\", \"attribute_id\");\n string message = FirstTokenString(error, \"message\", \"error\", \"text\", \"description\");\n builder.AppendLine(\" error: \" + (string.IsNullOrEmpty(code) ? string.Empty : code + \" - \") + SafeText(message));\n }\n }\n\n private static string SafeText(string value)\n {\n return string.IsNullOrEmpty(value) ? \"(none)\" : value;\n }\n\n private static void Validate1688Options(SourcingOptions options)\n {\n if (options == null)\n {\n throw new ArgumentNullException(\"options\");\n }\n\n if (string.IsNullOrEmpty(options.ApiKey))\n {\n throw new InvalidOperationException(\"1688 API key is required.\");\n }\n }\n\n private static int DecisionRank(string decision)\n {\n if (string.Equals(decision, \"Go\", StringComparison.OrdinalIgnoreCase)) return 3;\n if (string.Equals(decision, \"Watch\", StringComparison.OrdinalIgnoreCase)) return 2;\n return 1;\n }\n\n private static void ScoreCandidate(SourceProduct product, AppConfig config)\n {\n decimal score = 0m;\n List reasons = new List();\n\n if (product.PriceCny > 0)\n {\n score += 20m;\n if (config != null && config.IsFilterGmPrice && product.PriceCny < config.MinGmPirce)\n {\n reasons.Add(\"source price below configured minimum\");\n }\n }\n else\n {\n reasons.Add(\"missing source price\");\n }\n\n if (product.SalesCount > 0)\n {\n score += Math.Min(25m, product.SalesCount / 20m);\n }\n else\n {\n reasons.Add(\"missing sales signal\");\n }\n\n if (!string.IsNullOrEmpty(product.MainImage) || product.Images.Count > 0)\n {\n score += 20m;\n }\n else\n {\n reasons.Add(\"missing product image\");\n }\n\n if (product.Attributes.Count > 0)\n {\n score += Math.Min(15m, product.Attributes.Count * 2m);\n }\n\n if (!string.IsNullOrEmpty(product.ShopName))\n {\n score += 10m;\n }\n\n if (config != null && config.MinSaleNum > 0 && product.SalesCount > 0 && product.SalesCount < config.MinSaleNum)\n {\n reasons.Add(\"sales below configured minimum\");\n }\n\n product.Score = Math.Round(score, 2);\n product.Decision = reasons.Count == 0 && score >= 45m ? \"Go\" : (score >= 30m ? \"Watch\" : \"No-Go\");\n product.Reason = reasons.Count == 0 ? \"meets basic source, sales and content checks\" : string.Join(\"; \", reasons.ToArray());\n }\n\n private static void MergeProduct(SourceProduct target, SourceProduct detail)\n {\n if (target == null || detail == null)\n {\n return;\n }\n\n if (!string.IsNullOrEmpty(detail.Title)) target.Title = detail.Title;\n if (!string.IsNullOrEmpty(detail.SourceUrl)) target.SourceUrl = detail.SourceUrl;\n if (detail.PriceCny > 0) target.PriceCny = detail.PriceCny;\n if (!string.IsNullOrEmpty(detail.PriceText)) target.PriceText = detail.PriceText;\n if (detail.SalesCount > 0) target.SalesCount = detail.SalesCount;\n if (!string.IsNullOrEmpty(detail.ShopName)) target.ShopName = detail.ShopName;\n if (!string.IsNullOrEmpty(detail.ShopUrl)) target.ShopUrl = detail.ShopUrl;\n if (!string.IsNullOrEmpty(detail.MainImage)) target.MainImage = detail.MainImage;\n if (detail.Images.Count > 0) target.Images = detail.Images;\n\n foreach (KeyValuePair pair in detail.Attributes)\n {\n if (!target.Attributes.ContainsKey(pair.Key))\n {\n target.Attributes[pair.Key] = pair.Value;\n }\n }\n }\n\n private static void EnrichProductWithDeepSeek(SourceProduct product, SourcingOptions options, JArray categoryAttributes)\n {\n if (product == null)\n {\n return;\n }\n\n string deepSeekApiKey = Environment.GetEnvironmentVariable(DeepSeekApiKeyEnvVar);\n if (string.IsNullOrWhiteSpace(deepSeekApiKey))\n {\n if (string.IsNullOrEmpty(product.RussianTitle))\n {\n product.RussianTitle = Truncate(CleanPublicText(product.Title), 120);\n }\n\n return;\n }\n\n try\n {\n JObject source = new JObject();\n source[\"title_cn\"] = product.Title ?? string.Empty;\n source[\"price_cny\"] = product.PriceCny;\n source[\"source_url\"] = product.SourceUrl ?? string.Empty;\n source[\"keyword\"] = product.Keyword ?? product.EnglishKeyword ?? string.Empty;\n source[\"attributes_cn\"] = JObject.FromObject(product.Attributes ?? new Dictionary());\n source[\"ozon_required_attributes\"] = BuildDeepSeekAttributeHints(categoryAttributes);\n\n JObject request = new JObject();\n request[\"model\"] = \"deepseek-chat\";\n request[\"temperature\"] = 0.2;\n request[\"response_format\"] = new JObject(new JProperty(\"type\", \"json_object\"));\n JArray messages = new JArray();\n messages.Add(new JObject(\n new JProperty(\"role\", \"system\"),\n new JProperty(\"content\", \"You generate Ozon marketplace listings. Return strict JSON only. Russian title and description are mandatory. Search keyword must be precise English. Fill Ozon required attributes when a text value can be inferred; do not invent safety certifications.\")));\n messages.Add(new JObject(\n new JProperty(\"role\", \"user\"),\n new JProperty(\"content\",\n \"Create listing JSON for this 1688 product. Required schema: {\\\"search_keyword_en\\\":\\\"...\\\",\\\"title_ru\\\":\\\"...\\\",\\\"description_ru\\\":\\\"...\\\",\\\"ozon_attributes\\\":[{\\\"id\\\":123,\\\"value\\\":\\\"...\\\"}],\\\"dimensions\\\":{\\\"height_mm\\\":100,\\\"width_mm\\\":100,\\\"depth_mm\\\":100,\\\"weight_g\\\":500}}. Product data: \" +\n source.ToString(Formatting.None))));\n request[\"messages\"] = messages;\n\n JObject response = JObject.Parse(PostDeepSeekJson(request.ToString(Formatting.None), deepSeekApiKey));\n string content = Convert.ToString(response.SelectToken(\"choices[0].message.content\") ?? string.Empty);\n JObject listing = JObject.Parse(ExtractJsonObject(content));\n product.EnglishKeyword = CleanPublicText(Convert.ToString(listing[\"search_keyword_en\"] ?? string.Empty));\n product.RussianTitle = CleanPublicText(Convert.ToString(listing[\"title_ru\"] ?? string.Empty));\n product.RussianDescription = CleanPublicText(Convert.ToString(listing[\"description_ru\"] ?? string.Empty));\n\n JArray attrs = listing[\"ozon_attributes\"] as JArray;\n for (int i = 0; attrs != null && i < attrs.Count; i++)\n {\n JObject attr = attrs[i] as JObject;\n if (attr == null)\n {\n continue;\n }\n\n long id = 0;\n long.TryParse(Convert.ToString(attr[\"id\"] ?? string.Empty), out id);\n string value = CleanPublicText(Convert.ToString(attr[\"value\"] ?? string.Empty));\n if (id > 0 && !string.IsNullOrEmpty(value) && !product.OzonAttributes.ContainsKey(id))\n {\n product.OzonAttributes[id] = value;\n }\n }\n\n JObject dimensions = listing[\"dimensions\"] as JObject;\n if (dimensions != null)\n {\n AddDimensionAttribute(product, \"height_mm\", Convert.ToString(dimensions[\"height_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"width_mm\", Convert.ToString(dimensions[\"width_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"depth_mm\", Convert.ToString(dimensions[\"depth_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"weight_g\", Convert.ToString(dimensions[\"weight_g\"] ?? string.Empty));\n }\n }\n catch\n {\n if (string.IsNullOrEmpty(product.RussianTitle))\n {\n product.RussianTitle = Truncate(CleanPublicText(product.Title), 120);\n }\n }\n }\n\n private static JArray BuildDeepSeekAttributeHints(JArray attributes)\n {\n JArray hints = new JArray();\n for (int i = 0; attributes != null && i < attributes.Count && hints.Count < 30; i++)\n {\n JObject attr = attributes[i] as JObject;\n if (attr == null)\n {\n continue;\n }\n\n bool required = string.Equals(Convert.ToString(attr[\"is_required\"] ?? string.Empty), \"True\", StringComparison.OrdinalIgnoreCase) ||\n string.Equals(Convert.ToString(attr[\"required\"] ?? string.Empty), \"True\", StringComparison.OrdinalIgnoreCase);\n if (!required)\n {\n continue;\n }\n\n JObject hint = new JObject();\n hint[\"id\"] = attr[\"id\"];\n hint[\"name\"] = Convert.ToString(attr[\"name\"] ?? attr[\"attribute_name\"] ?? string.Empty);\n hint[\"type\"] = Convert.ToString(attr[\"type\"] ?? string.Empty);\n hint[\"dictionary_id\"] = attr[\"dictionary_id\"];\n hints.Add(hint);\n }\n\n return hints;\n }\n\n private static void AddDimensionAttribute(SourceProduct product, string name, string value)\n {\n if (product == null || string.IsNullOrEmpty(value))\n {\n return;\n }\n\n product.Attributes[name] = value;\n }\n\n private static List Search1688(string keyword, SourcingOptions options, int limit)\n {\n string provider = NormalizeProvider(options.Provider);\n string url = provider == \"dingdanxia\"\n ? DingdanxiaSearchEndpoint + \"?apikey=\" + Uri.EscapeDataString(options.ApiKey) + \"&q=\" + Uri.EscapeDataString(keyword) + \"&page=1\"\n : OneboundSearchEndpoint + \"?key=\" + Uri.EscapeDataString(options.ApiKey) + \"&secret=\" + Uri.EscapeDataString(options.ApiSecret ?? string.Empty) + \"&q=\" + Uri.EscapeDataString(keyword) + \"&page=1&sort=default&page_size=\" + limit;\n JObject json = JObject.Parse(GetJson(url));\n JArray items = FindFirstArray(json, new string[] { \"items.item\", \"result\", \"data\", \"items\" });\n List products = new List();\n\n if (items == null)\n {\n return products;\n }\n\n for (int i = 0; i < items.Count && products.Count < limit; i++)\n {\n JObject item = items[i] as JObject;\n if (item == null)\n {\n continue;\n }\n\n string offerId = FirstString(item, \"num_iid\", \"id\", \"offerId\", \"offer_id\");\n if (string.IsNullOrEmpty(offerId))\n {\n continue;\n }\n\n SourceProduct product = new SourceProduct();\n product.OfferId = offerId;\n product.Title = FirstString(item, \"title\", \"name\");\n product.SourceUrl = \"https://detail.1688.com/offer/\" + offerId + \".html\";\n product.PriceText = FirstString(item, \"price\", \"salePrice\", \"priceText\");\n product.PriceCny = ParseMoney(product.PriceText);\n product.MainImage = FirstString(item, \"pic_url\", \"image\", \"img\", \"picUrl\");\n if (!string.IsNullOrEmpty(product.MainImage)) product.Images.Add(product.MainImage);\n product.ShopName = FirstString(item, \"nick\", \"shop_name\", \"seller_nick\");\n product.ShopUrl = FirstString(item, \"seller_url\", \"shop_url\");\n product.SalesCount = ParseInt(FirstString(item, \"sales\", \"volume\", \"sold\", \"sale_count\"));\n products.Add(product);\n }\n\n return products;\n }\n\n private static SourceProduct Get1688Detail(string offerId, SourcingOptions options)\n {\n string provider = NormalizeProvider(options.Provider);\n string url = provider == \"dingdanxia\"\n ? DingdanxiaDetailEndpoint + \"?apikey=\" + Uri.EscapeDataString(options.ApiKey) + \"&num_iid=\" + Uri.EscapeDataString(offerId)\n : OneboundDetailEndpoint + \"?key=\" + Uri.EscapeDataString(options.ApiKey) + \"&secret=\" + Uri.EscapeDataString(options.ApiSecret ?? string.Empty) + \"&num_iid=\" + Uri.EscapeDataString(offerId) + \"&lang=zh-CN\";\n JObject json = JObject.Parse(GetJson(url));\n JObject item = FirstObject(json, \"item\", \"result\", \"data\");\n if (item == null)\n {\n return null;\n }\n\n SourceProduct product = new SourceProduct();\n product.OfferId = offerId;\n product.Title = FirstString(item, \"title\", \"name\");\n product.SourceUrl = \"https://detail.1688.com/offer/\" + offerId + \".html\";\n product.PriceText = FirstString(item, \"price\", \"orginal_price\", \"salePrice\");\n product.PriceCny = ParseMoney(product.PriceText);\n product.ShopName = FirstString(item, \"nick\", \"seller_nick\", \"shop_name\");\n product.ShopUrl = FirstString(item, \"seller_url\", \"shop_url\");\n product.SalesCount = ParseInt(FirstString(item, \"sales\", \"volume\", \"sold\", \"sale_count\"));\n\n AddImage(product, FirstString(item, \"pic_url\", \"image\", \"main_image\"));\n JArray itemImages = FindFirstArray(item, new string[] { \"item_imgs.item_img\", \"images\", \"image_urls\" });\n if (itemImages != null)\n {\n for (int i = 0; i < itemImages.Count; i++)\n {\n JObject imageObject = itemImages[i] as JObject;\n string image = imageObject == null ? Convert.ToString(itemImages[i]) : FirstString(imageObject, \"url\", \"image\", \"img\");\n AddImage(product, image);\n }\n }\n\n JObject props = item[\"props_list\"] as JObject;\n if (props != null)\n {\n foreach (JProperty property in props.Properties())\n {\n string key = property.Name;\n int colon = key.IndexOf(':');\n if (colon >= 0 && colon + 1 < key.Length)\n {\n key = key.Substring(colon + 1);\n }\n\n if (!product.Attributes.ContainsKey(key))\n {\n product.Attributes[key] = Convert.ToString(property.Value);\n }\n }\n }\n\n return product;\n }\n\n private static JObject BuildOzonImportItem(SourceProduct product, SourcingOptions options)\n {\n decimal multiplier = options.PriceMultiplier <= 0 ? 2.2m : options.PriceMultiplier;\n string currency = string.IsNullOrEmpty(options.CurrencyCode) ? \"CNY\" : options.CurrencyCode;\n decimal price = string.Equals(currency, \"RUB\", StringComparison.OrdinalIgnoreCase)\n ? Math.Ceiling(Math.Max(1m, product.PriceCny) * (options.RubPerCny <= 0 ? 12.5m : options.RubPerCny) * multiplier)\n : Math.Ceiling(Math.Max(1m, product.PriceCny) * multiplier);\n string offerId = \"LZ1688-\" + SafeOfferId(product.OfferId);\n string primaryImage = !string.IsNullOrEmpty(product.MainImage) ? product.MainImage : (product.Images.Count > 0 ? product.Images[0] : string.Empty);\n string title = !string.IsNullOrEmpty(product.RussianTitle) ? product.RussianTitle : product.Title;\n string description = !string.IsNullOrEmpty(product.RussianDescription)\n ? product.RussianDescription\n : CleanPublicText(product.Title) + \"\\nИсточник: \" + product.SourceUrl;\n\n JObject item = new JObject();\n item[\"description_category_id\"] = options.OzonCategoryId;\n item[\"type_id\"] = options.OzonTypeId;\n item[\"name\"] = Truncate(CleanPublicText(title), 500);\n item[\"offer_id\"] = offerId;\n item[\"barcode\"] = string.Empty;\n item[\"price\"] = price.ToString(\"0\");\n item[\"old_price\"] = Math.Ceiling(price * 1.25m).ToString(\"0\");\n item[\"currency_code\"] = currency;\n item[\"vat\"] = string.IsNullOrEmpty(options.Vat) ? \"0\" : options.Vat;\n item[\"height\"] = 100;\n item[\"depth\"] = 100;\n item[\"width\"] = 100;\n item[\"dimension_unit\"] = \"mm\";\n item[\"weight\"] = 500;\n item[\"weight_unit\"] = \"g\";\n item[\"primary_image\"] = primaryImage;\n\n JArray images = new JArray();\n for (int i = 0; i < product.Images.Count; i++)\n {\n if (product.Images[i] != primaryImage)\n {\n images.Add(product.Images[i]);\n }\n }\n item[\"images\"] = images;\n\n JArray attributes = new JArray();\n foreach (KeyValuePair pair in product.OzonAttributes)\n {\n if (pair.Key <= 0 || string.IsNullOrEmpty(pair.Value))\n {\n continue;\n }\n\n JObject attr = new JObject();\n attr[\"id\"] = pair.Key;\n JArray values = new JArray();\n values.Add(new JObject(new JProperty(\"value\", Truncate(CleanPublicText(pair.Value), 900))));\n attr[\"values\"] = values;\n attributes.Add(attr);\n }\n\n item[\"attributes\"] = attributes;\n item[\"description\"] = Truncate(CleanPublicText(description), 3000);\n return item;\n }\n\n private static string PostOzonJson(string path, string json, string clientId, string apiKey)\n {\n EnsureModernTls();\n string url = OzonSellerApiBaseUrl + path;\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);\n request.Method = \"POST\";\n request.ContentType = \"application/json\";\n request.Accept = \"application/json\";\n request.Timeout = 60000;\n request.Headers[\"Client-Id\"] = clientId;\n request.Headers[\"Api-Key\"] = apiKey;\n\n byte[] body = Encoding.UTF8.GetBytes(json);\n request.ContentLength = body.Length;\n using (Stream requestStream = request.GetRequestStream())\n {\n requestStream.Write(body, 0, body.Length);\n }\n\n return ReadResponse(request);\n }\n\n private static string PostDeepSeekJson(string json, string apiKey)\n {\n EnsureModernTls();\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(DeepSeekChatEndpoint);\n request.Method = \"POST\";\n request.ContentType = \"application/json\";\n request.Accept = \"application/json\";\n request.Timeout = 45000;\n request.Headers[\"Authorization\"] = \"Bearer \" + apiKey;\n\n byte[] body = Encoding.UTF8.GetBytes(json);\n request.ContentLength = body.Length;\n using (Stream requestStream = request.GetRequestStream())\n {\n requestStream.Write(body, 0, body.Length);\n }\n\n return ReadResponse(request);\n }\n\n private static string GetJson(string url)\n {\n EnsureModernTls();\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);\n request.Method = \"GET\";\n request.Accept = \"application/json\";\n request.UserAgent = \"OZON-PILOT/1.0\";\n request.Timeout = 45000;\n return ReadResponse(request);\n }\n\n private static void EnsureModernTls()\n {\n if (_tlsInitialized)\n {\n return;\n }\n\n // TLS 1.2 is required by Ozon Seller API. .NET 4.0 may not expose the enum names,\n // so use numeric values while still allowing older endpoints used by legacy suppliers.\n const int tls10 = 192;\n const int tls11 = 768;\n const int tls12 = 3072;\n ServicePointManager.SecurityProtocol =\n (SecurityProtocolType)(tls10 | tls11 | tls12);\n ServicePointManager.Expect100Continue = false;\n ServicePointManager.ServerCertificateValidationCallback =\n delegate { return true; };\n _tlsInitialized = true;\n }\n\n private static string ReadResponse(HttpWebRequest request)\n {\n try\n {\n using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())\n using (Stream stream = response.GetResponseStream())\n using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))\n {\n return reader.ReadToEnd();\n }\n }\n catch (WebException ex)\n {\n string detail = ex.Message;\n if (ex.Response != null)\n {\n using (Stream stream = ex.Response.GetResponseStream())\n using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))\n {\n detail = reader.ReadToEnd();\n }\n }\n\n throw new InvalidOperationException(detail, ex);\n }\n }\n\n private static string NormalizeProvider(string value)\n {\n string provider = string.IsNullOrEmpty(value) ? \"onebound\" : value.Trim().ToLowerInvariant();\n return provider == \"dingdanxia\" ? \"dingdanxia\" : \"onebound\";\n }\n\n private static void AddImage(SourceProduct product, string image)\n {\n if (string.IsNullOrEmpty(image))\n {\n return;\n }\n\n if (string.IsNullOrEmpty(product.MainImage))\n {\n product.MainImage = image;\n }\n\n if (!product.Images.Contains(image))\n {\n product.Images.Add(image);\n }\n }\n\n private static JArray FindFirstArray(JObject obj, string[] paths)\n {\n for (int i = 0; i < paths.Length; i++)\n {\n JToken token = obj.SelectToken(paths[i]);\n if (token is JArray)\n {\n return (JArray)token;\n }\n }\n\n return null;\n }\n\n private static JObject FirstObject(JObject obj, params string[] names)\n {\n for (int i = 0; i < names.Length; i++)\n {\n JObject child = obj[names[i]] as JObject;\n if (child != null)\n {\n return child;\n }\n }\n\n return null;\n }\n\n private static string FirstString(JObject obj, params string[] names)\n {\n for (int i = 0; i < names.Length; i++)\n {\n JToken token = obj[names[i]];\n if (token != null && token.Type != JTokenType.Null)\n {\n string text = Convert.ToString(token);\n if (!string.IsNullOrEmpty(text))\n {\n return text.Trim();\n }\n }\n }\n\n return string.Empty;\n }\n\n private static decimal ParseMoney(string text)\n {\n if (string.IsNullOrEmpty(text))\n {\n return 0m;\n }\n\n StringBuilder builder = new StringBuilder();\n bool dot = false;\n for (int i = 0; i < text.Length; i++)\n {\n char c = text[i];\n if (char.IsDigit(c))\n {\n builder.Append(c);\n }\n else if (c == '.' && !dot)\n {\n builder.Append(c);\n dot = true;\n }\n else if (builder.Length > 0)\n {\n break;\n }\n }\n\n decimal value;\n return decimal.TryParse(builder.ToString(), out value) ? value : 0m;\n }\n\n private static int ParseInt(string text)\n {\n int value;\n return int.TryParse(text, out value) ? value : 0;\n }\n\n private static string SafeOfferId(string value)\n {\n if (string.IsNullOrEmpty(value))\n {\n return Guid.NewGuid().ToString(\"N\").Substring(0, 12);\n }\n\n StringBuilder builder = new StringBuilder();\n for (int i = 0; i < value.Length; i++)\n {\n char c = value[i];\n if (char.IsLetterOrDigit(c) || c == '-' || c == '_')\n {\n builder.Append(c);\n }\n }\n\n return builder.Length == 0 ? Guid.NewGuid().ToString(\"N\").Substring(0, 12) : builder.ToString();\n }\n\n private static void WriteLog(Action log, string message)\n {\n if (log != null)\n {\n log(message);\n }\n }\n\n private static string CleanPublicText(string value)\n {\n string text = value ?? string.Empty;\n text = text.Replace(\"Ozon hot\", string.Empty).Replace(\"1688 hot\", string.Empty);\n return WebUtility.HtmlDecode(text).Replace(\"\\r\", \" \").Replace(\"\\n\", \" \").Replace(\"\\t\", \" \").Trim();\n }\n\n private static string ExtractJsonObject(string value)\n {\n if (string.IsNullOrEmpty(value))\n {\n return \"{}\";\n }\n\n int start = value.IndexOf('{');\n int end = value.LastIndexOf('}');\n if (start >= 0 && end > start)\n {\n return value.Substring(start, end - start + 1);\n }\n\n return value;\n }\n\n private static string Truncate(string value, int maxLength)\n {\n if (string.IsNullOrEmpty(value) || value.Length <= maxLength)\n {\n return value ?? string.Empty;\n }\n\n return value.Substring(0, maxLength);\n }\n }\n}\n"}, "a6515a6f25239c5baa2ecbf8b46067216d9163e0": {"t": 1785756821.7651563, "n": 48182, "h": "074eb581b157727b", "c": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Net;\nusing System.Text;\nusing System.Threading;\nusing Newtonsoft.Json;\nusing Newtonsoft.Json.Linq;\n\nnamespace LitchiOzonRecovery\n{\n internal sealed class SourcingSeed\n {\n public string Keyword { get; set; }\n public string Category { get; set; }\n public string Why { get; set; }\n }\n\n internal sealed class SourcingOptions\n {\n public string Provider { get; set; }\n public string ApiKey { get; set; }\n public string ApiSecret { get; set; }\n public int PerKeywordLimit { get; set; }\n public int DetailLimit { get; set; }\n public decimal RubPerCny { get; set; }\n public long OzonCategoryId { get; set; }\n public long OzonTypeId { get; set; }\n public decimal PriceMultiplier { get; set; }\n public string CurrencyCode { get; set; }\n public string Vat { get; set; }\n }\n\n internal sealed class SourceProduct\n {\n public string OfferId { get; set; }\n public string Title { get; set; }\n public string RussianTitle { get; set; }\n public string RussianDescription { get; set; }\n public string EnglishKeyword { get; set; }\n public string SourceUrl { get; set; }\n public decimal PriceCny { get; set; }\n public string PriceText { get; set; }\n public int SalesCount { get; set; }\n public string ShopName { get; set; }\n public string ShopUrl { get; set; }\n public string MainImage { get; set; }\n public List Images { get; set; }\n public Dictionary Attributes { get; set; }\n public Dictionary OzonAttributes { get; set; }\n public string Keyword { get; set; }\n public decimal Score { get; set; }\n public string Decision { get; set; }\n public string Reason { get; set; }\n\n public SourceProduct()\n {\n Images = new List();\n Attributes = new Dictionary();\n OzonAttributes = new Dictionary();\n }\n }\n\n internal sealed class SourcingResult\n {\n public List Products { get; private set; }\n public List Logs { get; private set; }\n\n public SourcingResult()\n {\n Products = new List();\n Logs = new List();\n }\n }\n\n internal sealed class OzonImportResult\n {\n public bool Success { get; set; }\n public string TaskId { get; set; }\n public string RawResponse { get; set; }\n public string ErrorMessage { get; set; }\n public string ImportInfoResponse { get; set; }\n public string ImportSummary { get; set; }\n }\n\n internal sealed class ProductAutomationService\n {\n private const string OneboundSearchEndpoint = \"https://api-gw.onebound.cn/1688/item_search/\";\n private const string OneboundDetailEndpoint = \"https://api-gw.onebound.cn/1688/item_get/\";\n private const string DingdanxiaSearchEndpoint = \"https://api.dingdanxia.com/1688/item_search\";\n private const string DingdanxiaDetailEndpoint = \"https://api.dingdanxia.com/1688/item_get\";\n private const string OzonSellerApiBaseUrl = \"https://api-seller.ozon.ru\";\n private const string DeepSeekChatEndpoint = \"https://api.deepseek.com/chat/completions\";\n private const string DeepSeekApiKeyEnvVar = \"DEEPSEEK_API_KEY\";\n private static bool _tlsInitialized;\n private readonly OzonProductImageAdaptationModule _imageAdapter = new OzonProductImageAdaptationModule();\n\n public SourcingResult Collect1688Candidates(IList seeds, AppConfig config, SourcingOptions options)\n {\n SourcingResult result = new SourcingResult();\n if (seeds == null || seeds.Count == 0)\n {\n result.Logs.Add(\"No keyword seeds.\");\n return result;\n }\n\n Validate1688Options(options);\n int perKeywordLimit = options.PerKeywordLimit <= 0 ? 5 : options.PerKeywordLimit;\n int detailLimit = options.DetailLimit <= 0 ? 12 : options.DetailLimit;\n Dictionary byOfferId = new Dictionary();\n\n for (int i = 0; i < seeds.Count; i++)\n {\n SourcingSeed seed = seeds[i];\n if (seed == null || string.IsNullOrEmpty(seed.Keyword))\n {\n continue;\n }\n\n result.Logs.Add(\"Search 1688: \" + seed.Keyword);\n List found = Search1688(seed.Keyword, options, perKeywordLimit);\n result.Logs.Add(\" found \" + found.Count + \" cards\");\n\n for (int j = 0; j < found.Count; j++)\n {\n SourceProduct product = found[j];\n product.Keyword = seed.Keyword;\n if (string.IsNullOrEmpty(product.OfferId) || byOfferId.ContainsKey(product.OfferId))\n {\n continue;\n }\n\n byOfferId[product.OfferId] = product;\n }\n }\n\n List candidates = new List(byOfferId.Values);\n candidates.Sort(delegate(SourceProduct left, SourceProduct right)\n {\n return right.SalesCount.CompareTo(left.SalesCount);\n });\n\n int enrichCount = Math.Min(detailLimit, candidates.Count);\n for (int i = 0; i < enrichCount; i++)\n {\n try\n {\n SourceProduct detail = Get1688Detail(candidates[i].OfferId, options);\n MergeProduct(candidates[i], detail);\n }\n catch (Exception ex)\n {\n result.Logs.Add(\" detail failed \" + candidates[i].OfferId + \": \" + ex.Message);\n }\n }\n\n for (int i = 0; i < candidates.Count; i++)\n {\n ScoreCandidate(candidates[i], config);\n }\n\n candidates.Sort(delegate(SourceProduct left, SourceProduct right)\n {\n int decision = DecisionRank(right.Decision).CompareTo(DecisionRank(left.Decision));\n if (decision != 0) return decision;\n return right.Score.CompareTo(left.Score);\n });\n\n result.Products.AddRange(candidates);\n result.Logs.Add(\"Final candidates: \" + result.Products.Count);\n return result;\n }\n\n public string GenerateEnglishCategoryKeyword(string categoryText)\n {\n if (string.IsNullOrEmpty(categoryText))\n {\n return string.Empty;\n }\n\n try\n {\n JObject request = new JObject();\n request[\"model\"] = \"deepseek-chat\";\n request[\"temperature\"] = 0.1;\n request[\"response_format\"] = new JObject(new JProperty(\"type\", \"json_object\"));\n\n JArray messages = new JArray();\n messages.Add(new JObject(\n new JProperty(\"role\", \"system\"),\n new JProperty(\"content\", \"Return strict JSON only. Convert marketplace category names into one precise English 1688 search keyword. Do not return generic words such as product, item, general merchandise, toy unless the category is actually toys. Keep it 2-5 words.\")));\n messages.Add(new JObject(\n new JProperty(\"role\", \"user\"),\n new JProperty(\"content\", \"Category: \" + categoryText + \"\\nSchema: {\\\"keyword\\\":\\\"precise english keyword\\\"}\")));\n request[\"messages\"] = messages;\n\n JObject response = JObject.Parse(PostDeepSeekJson(request.ToString(Formatting.None)));\n string content = Convert.ToString(response.SelectToken(\"choices[0].message.content\") ?? string.Empty);\n JObject json = JObject.Parse(ExtractJsonObject(content));\n return CleanPublicText(Convert.ToString(json[\"keyword\"] ?? string.Empty));\n }\n catch\n {\n return string.Empty;\n }\n }\n\n public OzonImportResult UploadToOzon(IList products, SourcingOptions options, string clientId, string apiKey)\n {\n return UploadToOzon(products, options, clientId, apiKey, null);\n }\n\n public OzonImportResult UploadToOzon(IList products, SourcingOptions options, string clientId, string apiKey, Action log)\n {\n if (products == null || products.Count == 0)\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"No products selected.\" };\n }\n\n if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(apiKey))\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"Ozon Client-Id and Api-Key are required.\" };\n }\n\n if (options.OzonCategoryId <= 0 || options.OzonTypeId <= 0)\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"Ozon category id and type id are required.\" };\n }\n\n JArray items = new JArray();\n JArray categoryAttributes = LoadOzonCategoryAttributes(options, clientId, apiKey);\n WriteLog(log, \"[upload] selected products after filtering: \" + products.Count + \".\");\n for (int i = 0; i < products.Count; i++)\n {\n SourceProduct product = products[i];\n if (product == null)\n {\n continue;\n }\n\n WriteLog(log, \"[upload] prepare \" + (i + 1) + \"/\" + products.Count + \": \" + SafeOfferId(product.OfferId));\n EnrichProductWithDeepSeek(product, options, categoryAttributes);\n _imageAdapter.AdaptPassedProductImage(product, log);\n items.Add(BuildOzonImportItem(product, options));\n }\n\n JObject payload = new JObject();\n payload[\"items\"] = items;\n string response = PostOzonJson(\"/v3/product/import\", payload.ToString(Formatting.None), clientId, apiKey);\n JObject parsed = JObject.Parse(response);\n string taskId = Convert.ToString(parsed.SelectToken(\"result.task_id\") ?? parsed.SelectToken(\"task_id\") ?? string.Empty);\n return new OzonImportResult\n {\n Success = true,\n TaskId = taskId,\n RawResponse = response\n };\n }\n\n public string GetOzonImportInfo(string taskId, string clientId, string apiKey)\n {\n JObject payload = new JObject();\n payload[\"task_id\"] = taskId;\n return PostOzonJson(\"/v1/product/import/info\", payload.ToString(Formatting.None), clientId, apiKey);\n }\n\n private JArray LoadOzonCategoryAttributes(SourcingOptions options, string clientId, string apiKey)\n {\n try\n {\n if (options == null || options.OzonCategoryId <= 0 || options.OzonTypeId <= 0)\n {\n return new JArray();\n }\n\n JObject payload = new JObject();\n payload[\"description_category_id\"] = options.OzonCategoryId;\n payload[\"type_id\"] = options.OzonTypeId;\n payload[\"language\"] = \"RU\";\n JObject root = JObject.Parse(PostOzonJson(\"/v1/description-category/attribute\", payload.ToString(Formatting.None), clientId, apiKey));\n JArray result = root[\"result\"] as JArray;\n return result ?? new JArray();\n }\n catch\n {\n return new JArray();\n }\n }\n\n public OzonImportResult WaitForOzonImportInfo(string taskId, string clientId, string apiKey, int attempts, int delayMs)\n {\n OzonImportResult result = new OzonImportResult();\n result.TaskId = taskId;\n if (string.IsNullOrEmpty(taskId))\n {\n result.Success = false;\n result.ErrorMessage = \"Ozon returned no task_id.\";\n return result;\n }\n\n int count = attempts <= 0 ? 6 : attempts;\n int wait = delayMs <= 0 ? 5000 : delayMs;\n string lastResponse = string.Empty;\n for (int i = 0; i < count; i++)\n {\n if (i > 0)\n {\n Thread.Sleep(wait);\n }\n\n lastResponse = GetOzonImportInfo(taskId, clientId, apiKey);\n string summary = BuildOzonImportSummary(lastResponse);\n if (!IsImportStillProcessing(summary))\n {\n result.Success = !HasImportErrors(summary);\n result.ImportInfoResponse = lastResponse;\n result.ImportSummary = summary;\n return result;\n }\n }\n\n result.Success = false;\n result.ImportInfoResponse = lastResponse;\n result.ImportSummary = \"Ozon import task is still processing. task_id=\" + taskId + Environment.NewLine +\n BuildOzonImportSummary(lastResponse);\n return result;\n }\n\n public string BuildOzonImportSummary(string response)\n {\n if (string.IsNullOrEmpty(response))\n {\n return \"Empty Ozon import info response.\";\n }\n\n try\n {\n JObject root = JObject.Parse(response);\n StringBuilder builder = new StringBuilder();\n JToken result = root[\"result\"] ?? root;\n string taskStatus = FirstTokenString(result, \"status\", \"state\", \"task_status\");\n if (!string.IsNullOrEmpty(taskStatus))\n {\n builder.AppendLine(\"task status: \" + taskStatus);\n }\n\n JArray items = FindArray(result, \"items\", \"products\", \"errors\");\n if (items == null || items.Count == 0)\n {\n string message = FirstTokenString(root, \"message\", \"error\");\n if (!string.IsNullOrEmpty(message))\n {\n builder.AppendLine(\"message: \" + message);\n }\n\n if (builder.Length == 0)\n {\n builder.AppendLine(\"No item-level result yet; Ozon may still be processing.\");\n }\n\n return builder.ToString().Trim();\n }\n\n int ok = 0;\n int failed = 0;\n for (int i = 0; i < items.Count; i++)\n {\n JObject item = items[i] as JObject;\n if (item == null)\n {\n continue;\n }\n\n string offerId = FirstTokenString(item, \"offer_id\", \"offerId\", \"article\");\n string status = FirstTokenString(item, \"status\", \"state\");\n JArray errors = FindArray(item, \"errors\", \"error\");\n bool hasErrors = errors != null && errors.Count > 0;\n if (hasErrors || status.IndexOf(\"fail\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n status.IndexOf(\"error\", StringComparison.OrdinalIgnoreCase) >= 0)\n {\n failed += 1;\n builder.AppendLine(\"failed: \" + SafeText(offerId) + \" \" + SafeText(status));\n AppendErrorMessages(builder, errors);\n }\n else\n {\n ok += 1;\n builder.AppendLine(\"accepted: \" + SafeText(offerId) + \" \" + SafeText(status));\n }\n }\n\n builder.Insert(0, \"items accepted=\" + ok + \", failed=\" + failed + Environment.NewLine);\n return builder.ToString().Trim();\n }\n catch (Exception ex)\n {\n return \"Could not parse Ozon import info: \" + ex.Message + Environment.NewLine + response;\n }\n }\n\n private static bool HasImportErrors(string summary)\n {\n if (string.IsNullOrEmpty(summary))\n {\n return true;\n }\n\n string lower = summary.ToLowerInvariant();\n if (lower.IndexOf(\"failed=0\", StringComparison.OrdinalIgnoreCase) >= 0 &&\n lower.IndexOf(\"failed:\", StringComparison.OrdinalIgnoreCase) < 0 &&\n lower.IndexOf(\"error:\", StringComparison.OrdinalIgnoreCase) < 0)\n {\n return false;\n }\n\n return lower.IndexOf(\"failed:\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"error:\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"could not parse\", StringComparison.OrdinalIgnoreCase) >= 0;\n }\n\n private static bool IsImportStillProcessing(string summary)\n {\n if (string.IsNullOrEmpty(summary))\n {\n return true;\n }\n\n string lower = summary.ToLowerInvariant();\n return lower.IndexOf(\"processing\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"pending\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"no item-level result yet\", StringComparison.OrdinalIgnoreCase) >= 0;\n }\n\n public void ExportCandidates(string path, IList products)\n {\n JArray array = JArray.FromObject(products ?? new List());\n File.WriteAllText(path, array.ToString(Formatting.Indented), new UTF8Encoding(false));\n }\n\n private static string FirstTokenString(JToken token, params string[] names)\n {\n if (token == null)\n {\n return string.Empty;\n }\n\n for (int i = 0; i < names.Length; i++)\n {\n JToken child = token[names[i]];\n if (child != null && child.Type != JTokenType.Null)\n {\n string text = Convert.ToString(child);\n if (!string.IsNullOrEmpty(text))\n {\n return text.Trim();\n }\n }\n }\n\n return string.Empty;\n }\n\n private static JArray FindArray(JToken token, params string[] names)\n {\n if (token == null)\n {\n return null;\n }\n\n for (int i = 0; i < names.Length; i++)\n {\n JToken direct = token[names[i]];\n if (direct is JArray)\n {\n return (JArray)direct;\n }\n\n if (direct != null)\n {\n JArray nested = FindArray(direct, names);\n if (nested != null)\n {\n return nested;\n }\n }\n }\n\n JObject obj = token as JObject;\n if (obj != null)\n {\n foreach (JProperty property in obj.Properties())\n {\n if (property.Value is JArray)\n {\n for (int i = 0; i < names.Length; i++)\n {\n if (property.Name.IndexOf(names[i], StringComparison.OrdinalIgnoreCase) >= 0)\n {\n return (JArray)property.Value;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n private static void AppendErrorMessages(StringBuilder builder, JArray errors)\n {\n if (builder == null || errors == null)\n {\n return;\n }\n\n for (int i = 0; i < errors.Count; i++)\n {\n JObject error = errors[i] as JObject;\n if (error == null)\n {\n builder.AppendLine(\" error: \" + Convert.ToString(errors[i]));\n continue;\n }\n\n string code = FirstTokenString(error, \"code\", \"field\", \"attribute_id\");\n string message = FirstTokenString(error, \"message\", \"error\", \"text\", \"description\");\n builder.AppendLine(\" error: \" + (string.IsNullOrEmpty(code) ? string.Empty : code + \" - \") + SafeText(message));\n }\n }\n\n private static string SafeText(string value)\n {\n return string.IsNullOrEmpty(value) ? \"(none)\" : value;\n }\n\n private static void Validate1688Options(SourcingOptions options)\n {\n if (options == null)\n {\n throw new ArgumentNullException(\"options\");\n }\n\n if (string.IsNullOrEmpty(options.ApiKey))\n {\n throw new InvalidOperationException(\"1688 API key is required.\");\n }\n }\n\n private static int DecisionRank(string decision)\n {\n if (string.Equals(decision, \"Go\", StringComparison.OrdinalIgnoreCase)) return 3;\n if (string.Equals(decision, \"Watch\", StringComparison.OrdinalIgnoreCase)) return 2;\n return 1;\n }\n\n private static void ScoreCandidate(SourceProduct product, AppConfig config)\n {\n decimal score = 0m;\n List reasons = new List();\n\n if (product.PriceCny > 0)\n {\n score += 20m;\n if (config != null && config.IsFilterGmPrice && product.PriceCny < config.MinGmPirce)\n {\n reasons.Add(\"source price below configured minimum\");\n }\n }\n else\n {\n reasons.Add(\"missing source price\");\n }\n\n if (product.SalesCount > 0)\n {\n score += Math.Min(25m, product.SalesCount / 20m);\n }\n else\n {\n reasons.Add(\"missing sales signal\");\n }\n\n if (!string.IsNullOrEmpty(product.MainImage) || product.Images.Count > 0)\n {\n score += 20m;\n }\n else\n {\n reasons.Add(\"missing product image\");\n }\n\n if (product.Attributes.Count > 0)\n {\n score += Math.Min(15m, product.Attributes.Count * 2m);\n }\n\n if (!string.IsNullOrEmpty(product.ShopName))\n {\n score += 10m;\n }\n\n if (config != null && config.MinSaleNum > 0 && product.SalesCount > 0 && product.SalesCount < config.MinSaleNum)\n {\n reasons.Add(\"sales below configured minimum\");\n }\n\n product.Score = Math.Round(score, 2);\n product.Decision = reasons.Count == 0 && score >= 45m ? \"Go\" : (score >= 30m ? \"Watch\" : \"No-Go\");\n product.Reason = reasons.Count == 0 ? \"meets basic source, sales and content checks\" : string.Join(\"; \", reasons.ToArray());\n }\n\n private static void MergeProduct(SourceProduct target, SourceProduct detail)\n {\n if (target == null || detail == null)\n {\n return;\n }\n\n if (!string.IsNullOrEmpty(detail.Title)) target.Title = detail.Title;\n if (!string.IsNullOrEmpty(detail.SourceUrl)) target.SourceUrl = detail.SourceUrl;\n if (detail.PriceCny > 0) target.PriceCny = detail.PriceCny;\n if (!string.IsNullOrEmpty(detail.PriceText)) target.PriceText = detail.PriceText;\n if (detail.SalesCount > 0) target.SalesCount = detail.SalesCount;\n if (!string.IsNullOrEmpty(detail.ShopName)) target.ShopName = detail.ShopName;\n if (!string.IsNullOrEmpty(detail.ShopUrl)) target.ShopUrl = detail.ShopUrl;\n if (!string.IsNullOrEmpty(detail.MainImage)) target.MainImage = detail.MainImage;\n if (detail.Images.Count > 0) target.Images = detail.Images;\n\n foreach (KeyValuePair pair in detail.Attributes)\n {\n if (!target.Attributes.ContainsKey(pair.Key))\n {\n target.Attributes[pair.Key] = pair.Value;\n }\n }\n }\n\n private static void EnrichProductWithDeepSeek(SourceProduct product, SourcingOptions options, JArray categoryAttributes)\n {\n if (product == null)\n {\n return;\n }\n\n string deepSeekApiKey = Environment.GetEnvironmentVariable(DeepSeekApiKeyEnvVar);\n if (string.IsNullOrWhiteSpace(deepSeekApiKey))\n {\n if (string.IsNullOrEmpty(product.RussianTitle))\n {\n product.RussianTitle = Truncate(CleanPublicText(product.Title), 120);\n }\n\n return;\n }\n\n try\n {\n JObject source = new JObject();\n source[\"title_cn\"] = product.Title ?? string.Empty;\n source[\"price_cny\"] = product.PriceCny;\n source[\"source_url\"] = product.SourceUrl ?? string.Empty;\n source[\"keyword\"] = product.Keyword ?? product.EnglishKeyword ?? string.Empty;\n source[\"attributes_cn\"] = JObject.FromObject(product.Attributes ?? new Dictionary());\n source[\"ozon_required_attributes\"] = BuildDeepSeekAttributeHints(categoryAttributes);\n\n JObject request = new JObject();\n request[\"model\"] = \"deepseek-chat\";\n request[\"temperature\"] = 0.2;\n request[\"response_format\"] = new JObject(new JProperty(\"type\", \"json_object\"));\n JArray messages = new JArray();\n messages.Add(new JObject(\n new JProperty(\"role\", \"system\"),\n new JProperty(\"content\", \"You generate Ozon marketplace listings. Return strict JSON only. Russian title and description are mandatory. Search keyword must be precise English. Fill Ozon required attributes when a text value can be inferred; do not invent safety certifications.\")));\n messages.Add(new JObject(\n new JProperty(\"role\", \"user\"),\n new JProperty(\"content\",\n \"Create listing JSON for this 1688 product. Required schema: {\\\"search_keyword_en\\\":\\\"...\\\",\\\"title_ru\\\":\\\"...\\\",\\\"description_ru\\\":\\\"...\\\",\\\"ozon_attributes\\\":[{\\\"id\\\":123,\\\"value\\\":\\\"...\\\"}],\\\"dimensions\\\":{\\\"height_mm\\\":100,\\\"width_mm\\\":100,\\\"depth_mm\\\":100,\\\"weight_g\\\":500}}. Product data: \" +\n source.ToString(Formatting.None))));\n request[\"messages\"] = messages;\n\n JObject response = JObject.Parse(PostDeepSeekJson(request.ToString(Formatting.None), deepSeekApiKey));\n string content = Convert.ToString(response.SelectToken(\"choices[0].message.content\") ?? string.Empty);\n JObject listing = JObject.Parse(ExtractJsonObject(content));\n product.EnglishKeyword = CleanPublicText(Convert.ToString(listing[\"search_keyword_en\"] ?? string.Empty));\n product.RussianTitle = CleanPublicText(Convert.ToString(listing[\"title_ru\"] ?? string.Empty));\n product.RussianDescription = CleanPublicText(Convert.ToString(listing[\"description_ru\"] ?? string.Empty));\n\n JArray attrs = listing[\"ozon_attributes\"] as JArray;\n for (int i = 0; attrs != null && i < attrs.Count; i++)\n {\n JObject attr = attrs[i] as JObject;\n if (attr == null)\n {\n continue;\n }\n\n long id = 0;\n long.TryParse(Convert.ToString(attr[\"id\"] ?? string.Empty), out id);\n string value = CleanPublicText(Convert.ToString(attr[\"value\"] ?? string.Empty));\n if (id > 0 && !string.IsNullOrEmpty(value) && !product.OzonAttributes.ContainsKey(id))\n {\n product.OzonAttributes[id] = value;\n }\n }\n\n JObject dimensions = listing[\"dimensions\"] as JObject;\n if (dimensions != null)\n {\n AddDimensionAttribute(product, \"height_mm\", Convert.ToString(dimensions[\"height_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"width_mm\", Convert.ToString(dimensions[\"width_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"depth_mm\", Convert.ToString(dimensions[\"depth_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"weight_g\", Convert.ToString(dimensions[\"weight_g\"] ?? string.Empty));\n }\n }\n catch\n {\n if (string.IsNullOrEmpty(product.RussianTitle))\n {\n product.RussianTitle = Truncate(CleanPublicText(product.Title), 120);\n }\n }\n }\n\n private static JArray BuildDeepSeekAttributeHints(JArray attributes)\n {\n JArray hints = new JArray();\n for (int i = 0; attributes != null && i < attributes.Count && hints.Count < 30; i++)\n {\n JObject attr = attributes[i] as JObject;\n if (attr == null)\n {\n continue;\n }\n\n bool required = string.Equals(Convert.ToString(attr[\"is_required\"] ?? string.Empty), \"True\", StringComparison.OrdinalIgnoreCase) ||\n string.Equals(Convert.ToString(attr[\"required\"] ?? string.Empty), \"True\", StringComparison.OrdinalIgnoreCase);\n if (!required)\n {\n continue;\n }\n\n JObject hint = new JObject();\n hint[\"id\"] = attr[\"id\"];\n hint[\"name\"] = Convert.ToString(attr[\"name\"] ?? attr[\"attribute_name\"] ?? string.Empty);\n hint[\"type\"] = Convert.ToString(attr[\"type\"] ?? string.Empty);\n hint[\"dictionary_id\"] = attr[\"dictionary_id\"];\n hints.Add(hint);\n }\n\n return hints;\n }\n\n private static void AddDimensionAttribute(SourceProduct product, string name, string value)\n {\n if (product == null || string.IsNullOrEmpty(value))\n {\n return;\n }\n\n product.Attributes[name] = value;\n }\n\n private static List Search1688(string keyword, SourcingOptions options, int limit)\n {\n string provider = NormalizeProvider(options.Provider);\n string url = provider == \"dingdanxia\"\n ? DingdanxiaSearchEndpoint + \"?apikey=\" + Uri.EscapeDataString(options.ApiKey) + \"&q=\" + Uri.EscapeDataString(keyword) + \"&page=1\"\n : OneboundSearchEndpoint + \"?key=\" + Uri.EscapeDataString(options.ApiKey) + \"&secret=\" + Uri.EscapeDataString(options.ApiSecret ?? string.Empty) + \"&q=\" + Uri.EscapeDataString(keyword) + \"&page=1&sort=default&page_size=\" + limit;\n JObject json = JObject.Parse(GetJson(url));\n JArray items = FindFirstArray(json, new string[] { \"items.item\", \"result\", \"data\", \"items\" });\n List products = new List();\n\n if (items == null)\n {\n return products;\n }\n\n for (int i = 0; i < items.Count && products.Count < limit; i++)\n {\n JObject item = items[i] as JObject;\n if (item == null)\n {\n continue;\n }\n\n string offerId = FirstString(item, \"num_iid\", \"id\", \"offerId\", \"offer_id\");\n if (string.IsNullOrEmpty(offerId))\n {\n continue;\n }\n\n SourceProduct product = new SourceProduct();\n product.OfferId = offerId;\n product.Title = FirstString(item, \"title\", \"name\");\n product.SourceUrl = \"https://detail.1688.com/offer/\" + offerId + \".html\";\n product.PriceText = FirstString(item, \"price\", \"salePrice\", \"priceText\");\n product.PriceCny = ParseMoney(product.PriceText);\n product.MainImage = FirstString(item, \"pic_url\", \"image\", \"img\", \"picUrl\");\n if (!string.IsNullOrEmpty(product.MainImage)) product.Images.Add(product.MainImage);\n product.ShopName = FirstString(item, \"nick\", \"shop_name\", \"seller_nick\");\n product.ShopUrl = FirstString(item, \"seller_url\", \"shop_url\");\n product.SalesCount = ParseInt(FirstString(item, \"sales\", \"volume\", \"sold\", \"sale_count\"));\n products.Add(product);\n }\n\n return products;\n }\n\n private static SourceProduct Get1688Detail(string offerId, SourcingOptions options)\n {\n string provider = NormalizeProvider(options.Provider);\n string url = provider == \"dingdanxia\"\n ? DingdanxiaDetailEndpoint + \"?apikey=\" + Uri.EscapeDataString(options.ApiKey) + \"&num_iid=\" + Uri.EscapeDataString(offerId)\n : OneboundDetailEndpoint + \"?key=\" + Uri.EscapeDataString(options.ApiKey) + \"&secret=\" + Uri.EscapeDataString(options.ApiSecret ?? string.Empty) + \"&num_iid=\" + Uri.EscapeDataString(offerId) + \"&lang=zh-CN\";\n JObject json = JObject.Parse(GetJson(url));\n JObject item = FirstObject(json, \"item\", \"result\", \"data\");\n if (item == null)\n {\n return null;\n }\n\n SourceProduct product = new SourceProduct();\n product.OfferId = offerId;\n product.Title = FirstString(item, \"title\", \"name\");\n product.SourceUrl = \"https://detail.1688.com/offer/\" + offerId + \".html\";\n product.PriceText = FirstString(item, \"price\", \"orginal_price\", \"salePrice\");\n product.PriceCny = ParseMoney(product.PriceText);\n product.ShopName = FirstString(item, \"nick\", \"seller_nick\", \"shop_name\");\n product.ShopUrl = FirstString(item, \"seller_url\", \"shop_url\");\n product.SalesCount = ParseInt(FirstString(item, \"sales\", \"volume\", \"sold\", \"sale_count\"));\n\n AddImage(product, FirstString(item, \"pic_url\", \"image\", \"main_image\"));\n JArray itemImages = FindFirstArray(item, new string[] { \"item_imgs.item_img\", \"images\", \"image_urls\" });\n if (itemImages != null)\n {\n for (int i = 0; i < itemImages.Count; i++)\n {\n JObject imageObject = itemImages[i] as JObject;\n string image = imageObject == null ? Convert.ToString(itemImages[i]) : FirstString(imageObject, \"url\", \"image\", \"img\");\n AddImage(product, image);\n }\n }\n\n JObject props = item[\"props_list\"] as JObject;\n if (props != null)\n {\n foreach (JProperty property in props.Properties())\n {\n string key = property.Name;\n int colon = key.IndexOf(':');\n if (colon >= 0 && colon + 1 < key.Length)\n {\n key = key.Substring(colon + 1);\n }\n\n if (!product.Attributes.ContainsKey(key))\n {\n product.Attributes[key] = Convert.ToString(property.Value);\n }\n }\n }\n\n return product;\n }\n\n private static JObject BuildOzonImportItem(SourceProduct product, SourcingOptions options)\n {\n decimal multiplier = options.PriceMultiplier <= 0 ? 2.2m : options.PriceMultiplier;\n string currency = string.IsNullOrEmpty(options.CurrencyCode) ? \"CNY\" : options.CurrencyCode;\n decimal price = string.Equals(currency, \"RUB\", StringComparison.OrdinalIgnoreCase)\n ? Math.Ceiling(Math.Max(1m, product.PriceCny) * (options.RubPerCny <= 0 ? 12.5m : options.RubPerCny) * multiplier)\n : Math.Ceiling(Math.Max(1m, product.PriceCny) * multiplier);\n string offerId = \"LZ1688-\" + SafeOfferId(product.OfferId);\n string primaryImage = !string.IsNullOrEmpty(product.MainImage) ? product.MainImage : (product.Images.Count > 0 ? product.Images[0] : string.Empty);\n string title = !string.IsNullOrEmpty(product.RussianTitle) ? product.RussianTitle : product.Title;\n string description = !string.IsNullOrEmpty(product.RussianDescription)\n ? product.RussianDescription\n : CleanPublicText(product.Title) + \"\\nИсточник: \" + product.SourceUrl;\n\n JObject item = new JObject();\n item[\"description_category_id\"] = options.OzonCategoryId;\n item[\"type_id\"] = options.OzonTypeId;\n item[\"name\"] = Truncate(CleanPublicText(title), 500);\n item[\"offer_id\"] = offerId;\n item[\"barcode\"] = string.Empty;\n item[\"price\"] = price.ToString(\"0\");\n item[\"old_price\"] = Math.Ceiling(price * 1.25m).ToString(\"0\");\n item[\"currency_code\"] = currency;\n item[\"vat\"] = string.IsNullOrEmpty(options.Vat) ? \"0\" : options.Vat;\n item[\"height\"] = 100;\n item[\"depth\"] = 100;\n item[\"width\"] = 100;\n item[\"dimension_unit\"] = \"mm\";\n item[\"weight\"] = 500;\n item[\"weight_unit\"] = \"g\";\n item[\"primary_image\"] = primaryImage;\n\n JArray images = new JArray();\n for (int i = 0; i < product.Images.Count; i++)\n {\n if (product.Images[i] != primaryImage)\n {\n images.Add(product.Images[i]);\n }\n }\n item[\"images\"] = images;\n\n JArray attributes = new JArray();\n foreach (KeyValuePair pair in product.OzonAttributes)\n {\n if (pair.Key <= 0 || string.IsNullOrEmpty(pair.Value))\n {\n continue;\n }\n\n JObject attr = new JObject();\n attr[\"id\"] = pair.Key;\n JArray values = new JArray();\n values.Add(new JObject(new JProperty(\"value\", Truncate(CleanPublicText(pair.Value), 900))));\n attr[\"values\"] = values;\n attributes.Add(attr);\n }\n\n item[\"attributes\"] = attributes;\n item[\"description\"] = Truncate(CleanPublicText(description), 3000);\n return item;\n }\n\n private static string PostOzonJson(string path, string json, string clientId, string apiKey)\n {\n EnsureModernTls();\n string url = OzonSellerApiBaseUrl + path;\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);\n request.Method = \"POST\";\n request.ContentType = \"application/json\";\n request.Accept = \"application/json\";\n request.Timeout = 60000;\n request.Headers[\"Client-Id\"] = clientId;\n request.Headers[\"Api-Key\"] = apiKey;\n\n byte[] body = Encoding.UTF8.GetBytes(json);\n request.ContentLength = body.Length;\n using (Stream requestStream = request.GetRequestStream())\n {\n requestStream.Write(body, 0, body.Length);\n }\n\n return ReadResponse(request);\n }\n\n private static string PostDeepSeekJson(string json, string apiKey)\n {\n EnsureModernTls();\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(DeepSeekChatEndpoint);\n request.Method = \"POST\";\n request.ContentType = \"application/json\";\n request.Accept = \"application/json\";\n request.Timeout = 45000;\n request.Headers[\"Authorization\"] = \"Bearer \" + apiKey;\n\n byte[] body = Encoding.UTF8.GetBytes(json);\n request.ContentLength = body.Length;\n using (Stream requestStream = request.GetRequestStream())\n {\n requestStream.Write(body, 0, body.Length);\n }\n\n return ReadResponse(request);\n }\n\n private static string GetJson(string url)\n {\n EnsureModernTls();\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);\n request.Method = \"GET\";\n request.Accept = \"application/json\";\n request.UserAgent = \"OZON-PILOT/1.0\";\n request.Timeout = 45000;\n return ReadResponse(request);\n }\n\n private static void EnsureModernTls()\n {\n if (_tlsInitialized)\n {\n return;\n }\n\n // TLS 1.2 is required by Ozon Seller API. .NET 4.0 may not expose the enum names,\n // so use numeric values while still allowing older endpoints used by legacy suppliers.\n const int tls10 = 192;\n const int tls11 = 768;\n const int tls12 = 3072;\n ServicePointManager.SecurityProtocol =\n (SecurityProtocolType)(tls10 | tls11 | tls12);\n ServicePointManager.Expect100Continue = false;\n ServicePointManager.ServerCertificateValidationCallback =\n delegate { return true; };\n _tlsInitialized = true;\n }\n\n private static string ReadResponse(HttpWebRequest request)\n {\n try\n {\n using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())\n using (Stream stream = response.GetResponseStream())\n using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))\n {\n return reader.ReadToEnd();\n }\n }\n catch (WebException ex)\n {\n string detail = ex.Message;\n if (ex.Response != null)\n {\n using (Stream stream = ex.Response.GetResponseStream())\n using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))\n {\n detail = reader.ReadToEnd();\n }\n }\n\n throw new InvalidOperationException(detail, ex);\n }\n }\n\n private static string NormalizeProvider(string value)\n {\n string provider = string.IsNullOrEmpty(value) ? \"onebound\" : value.Trim().ToLowerInvariant();\n return provider == \"dingdanxia\" ? \"dingdanxia\" : \"onebound\";\n }\n\n private static void AddImage(SourceProduct product, string image)\n {\n if (string.IsNullOrEmpty(image))\n {\n return;\n }\n\n if (string.IsNullOrEmpty(product.MainImage))\n {\n product.MainImage = image;\n }\n\n if (!product.Images.Contains(image))\n {\n product.Images.Add(image);\n }\n }\n\n private static JArray FindFirstArray(JObject obj, string[] paths)\n {\n for (int i = 0; i < paths.Length; i++)\n {\n JToken token = obj.SelectToken(paths[i]);\n if (token is JArray)\n {\n return (JArray)token;\n }\n }\n\n return null;\n }\n\n private static JObject FirstObject(JObject obj, params string[] names)\n {\n for (int i = 0; i < names.Length; i++)\n {\n JObject child = obj[names[i]] as JObject;\n if (child != null)\n {\n return child;\n }\n }\n\n return null;\n }\n\n private static string FirstString(JObject obj, params string[] names)\n {\n for (int i = 0; i < names.Length; i++)\n {\n JToken token = obj[names[i]];\n if (token != null && token.Type != JTokenType.Null)\n {\n string text = Convert.ToString(token);\n if (!string.IsNullOrEmpty(text))\n {\n return text.Trim();\n }\n }\n }\n\n return string.Empty;\n }\n\n private static decimal ParseMoney(string text)\n {\n if (string.IsNullOrEmpty(text))\n {\n return 0m;\n }\n\n StringBuilder builder = new StringBuilder();\n bool dot = false;\n for (int i = 0; i < text.Length; i++)\n {\n char c = text[i];\n if (char.IsDigit(c))\n {\n builder.Append(c);\n }\n else if (c == '.' && !dot)\n {\n builder.Append(c);\n dot = true;\n }\n else if (builder.Length > 0)\n {\n break;\n }\n }\n\n decimal value;\n return decimal.TryParse(builder.ToString(), out value) ? value : 0m;\n }\n\n private static int ParseInt(string text)\n {\n int value;\n return int.TryParse(text, out value) ? value : 0;\n }\n\n private static string SafeOfferId(string value)\n {\n if (string.IsNullOrEmpty(value))\n {\n return Guid.NewGuid().ToString(\"N\").Substring(0, 12);\n }\n\n StringBuilder builder = new StringBuilder();\n for (int i = 0; i < value.Length; i++)\n {\n char c = value[i];\n if (char.IsLetterOrDigit(c) || c == '-' || c == '_')\n {\n builder.Append(c);\n }\n }\n\n return builder.Length == 0 ? Guid.NewGuid().ToString(\"N\").Substring(0, 12) : builder.ToString();\n }\n\n private static void WriteLog(Action log, string message)\n {\n if (log != null)\n {\n log(message);\n }\n }\n\n private static string CleanPublicText(string value)\n {\n string text = value ?? string.Empty;\n text = text.Replace(\"Ozon hot\", string.Empty).Replace(\"1688 hot\", string.Empty);\n return WebUtility.HtmlDecode(text).Replace(\"\\r\", \" \").Replace(\"\\n\", \" \").Replace(\"\\t\", \" \").Trim();\n }\n\n private static string ExtractJsonObject(string value)\n {\n if (string.IsNullOrEmpty(value))\n {\n return \"{}\";\n }\n\n int start = value.IndexOf('{');\n int end = value.LastIndexOf('}');\n if (start >= 0 && end > start)\n {\n return value.Substring(start, end - start + 1);\n }\n\n return value;\n }\n\n private static string Truncate(string value, int maxLength)\n {\n if (string.IsNullOrEmpty(value) || value.Length <= maxLength)\n {\n return value ?? string.Empty;\n }\n\n return value.Substring(0, maxLength);\n }\n }\n}\n"}, "12ac15e822d28c6334259c173d9017abe14fb906": {"t": 1785756823.5608778, "n": 47347, "h": "94b9199da1bf2747", "c": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Net;\nusing System.Text;\nusing System.Threading;\nusing Newtonsoft.Json;\nusing Newtonsoft.Json.Linq;\n\nnamespace LitchiOzonRecovery\n{\n internal sealed class SourcingSeed\n {\n public string Keyword { get; set; }\n public string Category { get; set; }\n public string Why { get; set; }\n }\n\n internal sealed class SourcingOptions\n {\n public string Provider { get; set; }\n public string ApiKey { get; set; }\n public string ApiSecret { get; set; }\n public int PerKeywordLimit { get; set; }\n public int DetailLimit { get; set; }\n public decimal RubPerCny { get; set; }\n public long OzonCategoryId { get; set; }\n public long OzonTypeId { get; set; }\n public decimal PriceMultiplier { get; set; }\n public string CurrencyCode { get; set; }\n public string Vat { get; set; }\n }\n\n internal sealed class SourceProduct\n {\n public string OfferId { get; set; }\n public string Title { get; set; }\n public string RussianTitle { get; set; }\n public string RussianDescription { get; set; }\n public string EnglishKeyword { get; set; }\n public string SourceUrl { get; set; }\n public decimal PriceCny { get; set; }\n public string PriceText { get; set; }\n public int SalesCount { get; set; }\n public string ShopName { get; set; }\n public string ShopUrl { get; set; }\n public string MainImage { get; set; }\n public List Images { get; set; }\n public Dictionary Attributes { get; set; }\n public Dictionary OzonAttributes { get; set; }\n public string Keyword { get; set; }\n public decimal Score { get; set; }\n public string Decision { get; set; }\n public string Reason { get; set; }\n\n public SourceProduct()\n {\n Images = new List();\n Attributes = new Dictionary();\n OzonAttributes = new Dictionary();\n }\n }\n\n internal sealed class SourcingResult\n {\n public List Products { get; private set; }\n public List Logs { get; private set; }\n\n public SourcingResult()\n {\n Products = new List();\n Logs = new List();\n }\n }\n\n internal sealed class OzonImportResult\n {\n public bool Success { get; set; }\n public string TaskId { get; set; }\n public string RawResponse { get; set; }\n public string ErrorMessage { get; set; }\n public string ImportInfoResponse { get; set; }\n public string ImportSummary { get; set; }\n }\n\n internal sealed class ProductAutomationService\n {\n private const string OneboundSearchEndpoint = \"https://api-gw.onebound.cn/1688/item_search/\";\n private const string OneboundDetailEndpoint = \"https://api-gw.onebound.cn/1688/item_get/\";\n private const string DingdanxiaSearchEndpoint = \"https://api.dingdanxia.com/1688/item_search\";\n private const string DingdanxiaDetailEndpoint = \"https://api.dingdanxia.com/1688/item_get\";\n private const string OzonSellerApiBaseUrl = \"https://api-seller.ozon.ru\";\n private const string DeepSeekChatEndpoint = \"https://api.deepseek.com/chat/completions\";\n private const string DeepSeekApiKeyEnvVar = \"DEEPSEEK_API_KEY\";\n private static bool _tlsInitialized;\n\n public SourcingResult Collect1688Candidates(IList seeds, AppConfig config, SourcingOptions options)\n {\n SourcingResult result = new SourcingResult();\n if (seeds == null || seeds.Count == 0)\n {\n result.Logs.Add(\"No keyword seeds.\");\n return result;\n }\n\n Validate1688Options(options);\n int perKeywordLimit = options.PerKeywordLimit <= 0 ? 5 : options.PerKeywordLimit;\n int detailLimit = options.DetailLimit <= 0 ? 12 : options.DetailLimit;\n Dictionary byOfferId = new Dictionary();\n\n for (int i = 0; i < seeds.Count; i++)\n {\n SourcingSeed seed = seeds[i];\n if (seed == null || string.IsNullOrEmpty(seed.Keyword))\n {\n continue;\n }\n\n result.Logs.Add(\"Search 1688: \" + seed.Keyword);\n List found = Search1688(seed.Keyword, options, perKeywordLimit);\n result.Logs.Add(\" found \" + found.Count + \" cards\");\n\n for (int j = 0; j < found.Count; j++)\n {\n SourceProduct product = found[j];\n product.Keyword = seed.Keyword;\n if (string.IsNullOrEmpty(product.OfferId) || byOfferId.ContainsKey(product.OfferId))\n {\n continue;\n }\n\n byOfferId[product.OfferId] = product;\n }\n }\n\n List candidates = new List(byOfferId.Values);\n candidates.Sort(delegate(SourceProduct left, SourceProduct right)\n {\n return right.SalesCount.CompareTo(left.SalesCount);\n });\n\n int enrichCount = Math.Min(detailLimit, candidates.Count);\n for (int i = 0; i < enrichCount; i++)\n {\n try\n {\n SourceProduct detail = Get1688Detail(candidates[i].OfferId, options);\n MergeProduct(candidates[i], detail);\n }\n catch (Exception ex)\n {\n result.Logs.Add(\" detail failed \" + candidates[i].OfferId + \": \" + ex.Message);\n }\n }\n\n for (int i = 0; i < candidates.Count; i++)\n {\n ScoreCandidate(candidates[i], config);\n }\n\n candidates.Sort(delegate(SourceProduct left, SourceProduct right)\n {\n int decision = DecisionRank(right.Decision).CompareTo(DecisionRank(left.Decision));\n if (decision != 0) return decision;\n return right.Score.CompareTo(left.Score);\n });\n\n result.Products.AddRange(candidates);\n result.Logs.Add(\"Final candidates: \" + result.Products.Count);\n return result;\n }\n\n public string GenerateEnglishCategoryKeyword(string categoryText)\n {\n if (string.IsNullOrEmpty(categoryText))\n {\n return string.Empty;\n }\n\n try\n {\n JObject request = new JObject();\n request[\"model\"] = \"deepseek-chat\";\n request[\"temperature\"] = 0.1;\n request[\"response_format\"] = new JObject(new JProperty(\"type\", \"json_object\"));\n\n JArray messages = new JArray();\n messages.Add(new JObject(\n new JProperty(\"role\", \"system\"),\n new JProperty(\"content\", \"Return strict JSON only. Convert marketplace category names into one precise English 1688 search keyword. Do not return generic words such as product, item, general merchandise, toy unless the category is actually toys. Keep it 2-5 words.\")));\n messages.Add(new JObject(\n new JProperty(\"role\", \"user\"),\n new JProperty(\"content\", \"Category: \" + categoryText + \"\\nSchema: {\\\"keyword\\\":\\\"precise english keyword\\\"}\")));\n request[\"messages\"] = messages;\n\n JObject response = JObject.Parse(PostDeepSeekJson(request.ToString(Formatting.None)));\n string content = Convert.ToString(response.SelectToken(\"choices[0].message.content\") ?? string.Empty);\n JObject json = JObject.Parse(ExtractJsonObject(content));\n return CleanPublicText(Convert.ToString(json[\"keyword\"] ?? string.Empty));\n }\n catch\n {\n return string.Empty;\n }\n }\n\n public OzonImportResult UploadToOzon(IList products, SourcingOptions options, string clientId, string apiKey)\n {\n if (products == null || products.Count == 0)\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"No products selected.\" };\n }\n\n if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(apiKey))\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"Ozon Client-Id and Api-Key are required.\" };\n }\n\n if (options.OzonCategoryId <= 0 || options.OzonTypeId <= 0)\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"Ozon category id and type id are required.\" };\n }\n\n JArray items = new JArray();\n JArray categoryAttributes = LoadOzonCategoryAttributes(options, clientId, apiKey);\n for (int i = 0; i < products.Count; i++)\n {\n SourceProduct product = products[i];\n if (product == null)\n {\n continue;\n }\n\n EnrichProductWithDeepSeek(product, options, categoryAttributes);\n items.Add(BuildOzonImportItem(product, options));\n }\n\n JObject payload = new JObject();\n payload[\"items\"] = items;\n string response = PostOzonJson(\"/v3/product/import\", payload.ToString(Formatting.None), clientId, apiKey);\n JObject parsed = JObject.Parse(response);\n string taskId = Convert.ToString(parsed.SelectToken(\"result.task_id\") ?? parsed.SelectToken(\"task_id\") ?? string.Empty);\n return new OzonImportResult\n {\n Success = true,\n TaskId = taskId,\n RawResponse = response\n };\n }\n\n public string GetOzonImportInfo(string taskId, string clientId, string apiKey)\n {\n JObject payload = new JObject();\n payload[\"task_id\"] = taskId;\n return PostOzonJson(\"/v1/product/import/info\", payload.ToString(Formatting.None), clientId, apiKey);\n }\n\n private JArray LoadOzonCategoryAttributes(SourcingOptions options, string clientId, string apiKey)\n {\n try\n {\n if (options == null || options.OzonCategoryId <= 0 || options.OzonTypeId <= 0)\n {\n return new JArray();\n }\n\n JObject payload = new JObject();\n payload[\"description_category_id\"] = options.OzonCategoryId;\n payload[\"type_id\"] = options.OzonTypeId;\n payload[\"language\"] = \"RU\";\n JObject root = JObject.Parse(PostOzonJson(\"/v1/description-category/attribute\", payload.ToString(Formatting.None), clientId, apiKey));\n JArray result = root[\"result\"] as JArray;\n return result ?? new JArray();\n }\n catch\n {\n return new JArray();\n }\n }\n\n public OzonImportResult WaitForOzonImportInfo(string taskId, string clientId, string apiKey, int attempts, int delayMs)\n {\n OzonImportResult result = new OzonImportResult();\n result.TaskId = taskId;\n if (string.IsNullOrEmpty(taskId))\n {\n result.Success = false;\n result.ErrorMessage = \"Ozon returned no task_id.\";\n return result;\n }\n\n int count = attempts <= 0 ? 6 : attempts;\n int wait = delayMs <= 0 ? 5000 : delayMs;\n string lastResponse = string.Empty;\n for (int i = 0; i < count; i++)\n {\n if (i > 0)\n {\n Thread.Sleep(wait);\n }\n\n lastResponse = GetOzonImportInfo(taskId, clientId, apiKey);\n string summary = BuildOzonImportSummary(lastResponse);\n if (!IsImportStillProcessing(summary))\n {\n result.Success = !HasImportErrors(summary);\n result.ImportInfoResponse = lastResponse;\n result.ImportSummary = summary;\n return result;\n }\n }\n\n result.Success = false;\n result.ImportInfoResponse = lastResponse;\n result.ImportSummary = \"Ozon import task is still processing. task_id=\" + taskId + Environment.NewLine +\n BuildOzonImportSummary(lastResponse);\n return result;\n }\n\n public string BuildOzonImportSummary(string response)\n {\n if (string.IsNullOrEmpty(response))\n {\n return \"Empty Ozon import info response.\";\n }\n\n try\n {\n JObject root = JObject.Parse(response);\n StringBuilder builder = new StringBuilder();\n JToken result = root[\"result\"] ?? root;\n string taskStatus = FirstTokenString(result, \"status\", \"state\", \"task_status\");\n if (!string.IsNullOrEmpty(taskStatus))\n {\n builder.AppendLine(\"task status: \" + taskStatus);\n }\n\n JArray items = FindArray(result, \"items\", \"products\", \"errors\");\n if (items == null || items.Count == 0)\n {\n string message = FirstTokenString(root, \"message\", \"error\");\n if (!string.IsNullOrEmpty(message))\n {\n builder.AppendLine(\"message: \" + message);\n }\n\n if (builder.Length == 0)\n {\n builder.AppendLine(\"No item-level result yet; Ozon may still be processing.\");\n }\n\n return builder.ToString().Trim();\n }\n\n int ok = 0;\n int failed = 0;\n for (int i = 0; i < items.Count; i++)\n {\n JObject item = items[i] as JObject;\n if (item == null)\n {\n continue;\n }\n\n string offerId = FirstTokenString(item, \"offer_id\", \"offerId\", \"article\");\n string status = FirstTokenString(item, \"status\", \"state\");\n JArray errors = FindArray(item, \"errors\", \"error\");\n bool hasErrors = errors != null && errors.Count > 0;\n if (hasErrors || status.IndexOf(\"fail\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n status.IndexOf(\"error\", StringComparison.OrdinalIgnoreCase) >= 0)\n {\n failed += 1;\n builder.AppendLine(\"failed: \" + SafeText(offerId) + \" \" + SafeText(status));\n AppendErrorMessages(builder, errors);\n }\n else\n {\n ok += 1;\n builder.AppendLine(\"accepted: \" + SafeText(offerId) + \" \" + SafeText(status));\n }\n }\n\n builder.Insert(0, \"items accepted=\" + ok + \", failed=\" + failed + Environment.NewLine);\n return builder.ToString().Trim();\n }\n catch (Exception ex)\n {\n return \"Could not parse Ozon import info: \" + ex.Message + Environment.NewLine + response;\n }\n }\n\n private static bool HasImportErrors(string summary)\n {\n if (string.IsNullOrEmpty(summary))\n {\n return true;\n }\n\n string lower = summary.ToLowerInvariant();\n if (lower.IndexOf(\"failed=0\", StringComparison.OrdinalIgnoreCase) >= 0 &&\n lower.IndexOf(\"failed:\", StringComparison.OrdinalIgnoreCase) < 0 &&\n lower.IndexOf(\"error:\", StringComparison.OrdinalIgnoreCase) < 0)\n {\n return false;\n }\n\n return lower.IndexOf(\"failed:\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"error:\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"could not parse\", StringComparison.OrdinalIgnoreCase) >= 0;\n }\n\n private static bool IsImportStillProcessing(string summary)\n {\n if (string.IsNullOrEmpty(summary))\n {\n return true;\n }\n\n string lower = summary.ToLowerInvariant();\n return lower.IndexOf(\"processing\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"pending\", StringComparison.OrdinalIgnoreCase) >= 0 ||\n lower.IndexOf(\"no item-level result yet\", StringComparison.OrdinalIgnoreCase) >= 0;\n }\n\n public void ExportCandidates(string path, IList products)\n {\n JArray array = JArray.FromObject(products ?? new List());\n File.WriteAllText(path, array.ToString(Formatting.Indented), new UTF8Encoding(false));\n }\n\n private static string FirstTokenString(JToken token, params string[] names)\n {\n if (token == null)\n {\n return string.Empty;\n }\n\n for (int i = 0; i < names.Length; i++)\n {\n JToken child = token[names[i]];\n if (child != null && child.Type != JTokenType.Null)\n {\n string text = Convert.ToString(child);\n if (!string.IsNullOrEmpty(text))\n {\n return text.Trim();\n }\n }\n }\n\n return string.Empty;\n }\n\n private static JArray FindArray(JToken token, params string[] names)\n {\n if (token == null)\n {\n return null;\n }\n\n for (int i = 0; i < names.Length; i++)\n {\n JToken direct = token[names[i]];\n if (direct is JArray)\n {\n return (JArray)direct;\n }\n\n if (direct != null)\n {\n JArray nested = FindArray(direct, names);\n if (nested != null)\n {\n return nested;\n }\n }\n }\n\n JObject obj = token as JObject;\n if (obj != null)\n {\n foreach (JProperty property in obj.Properties())\n {\n if (property.Value is JArray)\n {\n for (int i = 0; i < names.Length; i++)\n {\n if (property.Name.IndexOf(names[i], StringComparison.OrdinalIgnoreCase) >= 0)\n {\n return (JArray)property.Value;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n private static void AppendErrorMessages(StringBuilder builder, JArray errors)\n {\n if (builder == null || errors == null)\n {\n return;\n }\n\n for (int i = 0; i < errors.Count; i++)\n {\n JObject error = errors[i] as JObject;\n if (error == null)\n {\n builder.AppendLine(\" error: \" + Convert.ToString(errors[i]));\n continue;\n }\n\n string code = FirstTokenString(error, \"code\", \"field\", \"attribute_id\");\n string message = FirstTokenString(error, \"message\", \"error\", \"text\", \"description\");\n builder.AppendLine(\" error: \" + (string.IsNullOrEmpty(code) ? string.Empty : code + \" - \") + SafeText(message));\n }\n }\n\n private static string SafeText(string value)\n {\n return string.IsNullOrEmpty(value) ? \"(none)\" : value;\n }\n\n private static void Validate1688Options(SourcingOptions options)\n {\n if (options == null)\n {\n throw new ArgumentNullException(\"options\");\n }\n\n if (string.IsNullOrEmpty(options.ApiKey))\n {\n throw new InvalidOperationException(\"1688 API key is required.\");\n }\n }\n\n private static int DecisionRank(string decision)\n {\n if (string.Equals(decision, \"Go\", StringComparison.OrdinalIgnoreCase)) return 3;\n if (string.Equals(decision, \"Watch\", StringComparison.OrdinalIgnoreCase)) return 2;\n return 1;\n }\n\n private static void ScoreCandidate(SourceProduct product, AppConfig config)\n {\n decimal score = 0m;\n List reasons = new List();\n\n if (product.PriceCny > 0)\n {\n score += 20m;\n if (config != null && config.IsFilterGmPrice && product.PriceCny < config.MinGmPirce)\n {\n reasons.Add(\"source price below configured minimum\");\n }\n }\n else\n {\n reasons.Add(\"missing source price\");\n }\n\n if (product.SalesCount > 0)\n {\n score += Math.Min(25m, product.SalesCount / 20m);\n }\n else\n {\n reasons.Add(\"missing sales signal\");\n }\n\n if (!string.IsNullOrEmpty(product.MainImage) || product.Images.Count > 0)\n {\n score += 20m;\n }\n else\n {\n reasons.Add(\"missing product image\");\n }\n\n if (product.Attributes.Count > 0)\n {\n score += Math.Min(15m, product.Attributes.Count * 2m);\n }\n\n if (!string.IsNullOrEmpty(product.ShopName))\n {\n score += 10m;\n }\n\n if (config != null && config.MinSaleNum > 0 && product.SalesCount > 0 && product.SalesCount < config.MinSaleNum)\n {\n reasons.Add(\"sales below configured minimum\");\n }\n\n product.Score = Math.Round(score, 2);\n product.Decision = reasons.Count == 0 && score >= 45m ? \"Go\" : (score >= 30m ? \"Watch\" : \"No-Go\");\n product.Reason = reasons.Count == 0 ? \"meets basic source, sales and content checks\" : string.Join(\"; \", reasons.ToArray());\n }\n\n private static void MergeProduct(SourceProduct target, SourceProduct detail)\n {\n if (target == null || detail == null)\n {\n return;\n }\n\n if (!string.IsNullOrEmpty(detail.Title)) target.Title = detail.Title;\n if (!string.IsNullOrEmpty(detail.SourceUrl)) target.SourceUrl = detail.SourceUrl;\n if (detail.PriceCny > 0) target.PriceCny = detail.PriceCny;\n if (!string.IsNullOrEmpty(detail.PriceText)) target.PriceText = detail.PriceText;\n if (detail.SalesCount > 0) target.SalesCount = detail.SalesCount;\n if (!string.IsNullOrEmpty(detail.ShopName)) target.ShopName = detail.ShopName;\n if (!string.IsNullOrEmpty(detail.ShopUrl)) target.ShopUrl = detail.ShopUrl;\n if (!string.IsNullOrEmpty(detail.MainImage)) target.MainImage = detail.MainImage;\n if (detail.Images.Count > 0) target.Images = detail.Images;\n\n foreach (KeyValuePair pair in detail.Attributes)\n {\n if (!target.Attributes.ContainsKey(pair.Key))\n {\n target.Attributes[pair.Key] = pair.Value;\n }\n }\n }\n\n private static void EnrichProductWithDeepSeek(SourceProduct product, SourcingOptions options, JArray categoryAttributes)\n {\n if (product == null)\n {\n return;\n }\n\n string deepSeekApiKey = Environment.GetEnvironmentVariable(DeepSeekApiKeyEnvVar);\n if (string.IsNullOrWhiteSpace(deepSeekApiKey))\n {\n if (string.IsNullOrEmpty(product.RussianTitle))\n {\n product.RussianTitle = Truncate(CleanPublicText(product.Title), 120);\n }\n\n return;\n }\n\n try\n {\n JObject source = new JObject();\n source[\"title_cn\"] = product.Title ?? string.Empty;\n source[\"price_cny\"] = product.PriceCny;\n source[\"source_url\"] = product.SourceUrl ?? string.Empty;\n source[\"keyword\"] = product.Keyword ?? product.EnglishKeyword ?? string.Empty;\n source[\"attributes_cn\"] = JObject.FromObject(product.Attributes ?? new Dictionary());\n source[\"ozon_required_attributes\"] = BuildDeepSeekAttributeHints(categoryAttributes);\n\n JObject request = new JObject();\n request[\"model\"] = \"deepseek-chat\";\n request[\"temperature\"] = 0.2;\n request[\"response_format\"] = new JObject(new JProperty(\"type\", \"json_object\"));\n JArray messages = new JArray();\n messages.Add(new JObject(\n new JProperty(\"role\", \"system\"),\n new JProperty(\"content\", \"You generate Ozon marketplace listings. Return strict JSON only. Russian title and description are mandatory. Search keyword must be precise English. Fill Ozon required attributes when a text value can be inferred; do not invent safety certifications.\")));\n messages.Add(new JObject(\n new JProperty(\"role\", \"user\"),\n new JProperty(\"content\",\n \"Create listing JSON for this 1688 product. Required schema: {\\\"search_keyword_en\\\":\\\"...\\\",\\\"title_ru\\\":\\\"...\\\",\\\"description_ru\\\":\\\"...\\\",\\\"ozon_attributes\\\":[{\\\"id\\\":123,\\\"value\\\":\\\"...\\\"}],\\\"dimensions\\\":{\\\"height_mm\\\":100,\\\"width_mm\\\":100,\\\"depth_mm\\\":100,\\\"weight_g\\\":500}}. Product data: \" +\n source.ToString(Formatting.None))));\n request[\"messages\"] = messages;\n\n JObject response = JObject.Parse(PostDeepSeekJson(request.ToString(Formatting.None), deepSeekApiKey));\n string content = Convert.ToString(response.SelectToken(\"choices[0].message.content\") ?? string.Empty);\n JObject listing = JObject.Parse(ExtractJsonObject(content));\n product.EnglishKeyword = CleanPublicText(Convert.ToString(listing[\"search_keyword_en\"] ?? string.Empty));\n product.RussianTitle = CleanPublicText(Convert.ToString(listing[\"title_ru\"] ?? string.Empty));\n product.RussianDescription = CleanPublicText(Convert.ToString(listing[\"description_ru\"] ?? string.Empty));\n\n JArray attrs = listing[\"ozon_attributes\"] as JArray;\n for (int i = 0; attrs != null && i < attrs.Count; i++)\n {\n JObject attr = attrs[i] as JObject;\n if (attr == null)\n {\n continue;\n }\n\n long id = 0;\n long.TryParse(Convert.ToString(attr[\"id\"] ?? string.Empty), out id);\n string value = CleanPublicText(Convert.ToString(attr[\"value\"] ?? string.Empty));\n if (id > 0 && !string.IsNullOrEmpty(value) && !product.OzonAttributes.ContainsKey(id))\n {\n product.OzonAttributes[id] = value;\n }\n }\n\n JObject dimensions = listing[\"dimensions\"] as JObject;\n if (dimensions != null)\n {\n AddDimensionAttribute(product, \"height_mm\", Convert.ToString(dimensions[\"height_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"width_mm\", Convert.ToString(dimensions[\"width_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"depth_mm\", Convert.ToString(dimensions[\"depth_mm\"] ?? string.Empty));\n AddDimensionAttribute(product, \"weight_g\", Convert.ToString(dimensions[\"weight_g\"] ?? string.Empty));\n }\n }\n catch\n {\n if (string.IsNullOrEmpty(product.RussianTitle))\n {\n product.RussianTitle = Truncate(CleanPublicText(product.Title), 120);\n }\n }\n }\n\n private static JArray BuildDeepSeekAttributeHints(JArray attributes)\n {\n JArray hints = new JArray();\n for (int i = 0; attributes != null && i < attributes.Count && hints.Count < 30; i++)\n {\n JObject attr = attributes[i] as JObject;\n if (attr == null)\n {\n continue;\n }\n\n bool required = string.Equals(Convert.ToString(attr[\"is_required\"] ?? string.Empty), \"True\", StringComparison.OrdinalIgnoreCase) ||\n string.Equals(Convert.ToString(attr[\"required\"] ?? string.Empty), \"True\", StringComparison.OrdinalIgnoreCase);\n if (!required)\n {\n continue;\n }\n\n JObject hint = new JObject();\n hint[\"id\"] = attr[\"id\"];\n hint[\"name\"] = Convert.ToString(attr[\"name\"] ?? attr[\"attribute_name\"] ?? string.Empty);\n hint[\"type\"] = Convert.ToString(attr[\"type\"] ?? string.Empty);\n hint[\"dictionary_id\"] = attr[\"dictionary_id\"];\n hints.Add(hint);\n }\n\n return hints;\n }\n\n private static void AddDimensionAttribute(SourceProduct product, string name, string value)\n {\n if (product == null || string.IsNullOrEmpty(value))\n {\n return;\n }\n\n product.Attributes[name] = value;\n }\n\n private static List Search1688(string keyword, SourcingOptions options, int limit)\n {\n string provider = NormalizeProvider(options.Provider);\n string url = provider == \"dingdanxia\"\n ? DingdanxiaSearchEndpoint + \"?apikey=\" + Uri.EscapeDataString(options.ApiKey) + \"&q=\" + Uri.EscapeDataString(keyword) + \"&page=1\"\n : OneboundSearchEndpoint + \"?key=\" + Uri.EscapeDataString(options.ApiKey) + \"&secret=\" + Uri.EscapeDataString(options.ApiSecret ?? string.Empty) + \"&q=\" + Uri.EscapeDataString(keyword) + \"&page=1&sort=default&page_size=\" + limit;\n JObject json = JObject.Parse(GetJson(url));\n JArray items = FindFirstArray(json, new string[] { \"items.item\", \"result\", \"data\", \"items\" });\n List products = new List();\n\n if (items == null)\n {\n return products;\n }\n\n for (int i = 0; i < items.Count && products.Count < limit; i++)\n {\n JObject item = items[i] as JObject;\n if (item == null)\n {\n continue;\n }\n\n string offerId = FirstString(item, \"num_iid\", \"id\", \"offerId\", \"offer_id\");\n if (string.IsNullOrEmpty(offerId))\n {\n continue;\n }\n\n SourceProduct product = new SourceProduct();\n product.OfferId = offerId;\n product.Title = FirstString(item, \"title\", \"name\");\n product.SourceUrl = \"https://detail.1688.com/offer/\" + offerId + \".html\";\n product.PriceText = FirstString(item, \"price\", \"salePrice\", \"priceText\");\n product.PriceCny = ParseMoney(product.PriceText);\n product.MainImage = FirstString(item, \"pic_url\", \"image\", \"img\", \"picUrl\");\n if (!string.IsNullOrEmpty(product.MainImage)) product.Images.Add(product.MainImage);\n product.ShopName = FirstString(item, \"nick\", \"shop_name\", \"seller_nick\");\n product.ShopUrl = FirstString(item, \"seller_url\", \"shop_url\");\n product.SalesCount = ParseInt(FirstString(item, \"sales\", \"volume\", \"sold\", \"sale_count\"));\n products.Add(product);\n }\n\n return products;\n }\n\n private static SourceProduct Get1688Detail(string offerId, SourcingOptions options)\n {\n string provider = NormalizeProvider(options.Provider);\n string url = provider == \"dingdanxia\"\n ? DingdanxiaDetailEndpoint + \"?apikey=\" + Uri.EscapeDataString(options.ApiKey) + \"&num_iid=\" + Uri.EscapeDataString(offerId)\n : OneboundDetailEndpoint + \"?key=\" + Uri.EscapeDataString(options.ApiKey) + \"&secret=\" + Uri.EscapeDataString(options.ApiSecret ?? string.Empty) + \"&num_iid=\" + Uri.EscapeDataString(offerId) + \"&lang=zh-CN\";\n JObject json = JObject.Parse(GetJson(url));\n JObject item = FirstObject(json, \"item\", \"result\", \"data\");\n if (item == null)\n {\n return null;\n }\n\n SourceProduct product = new SourceProduct();\n product.OfferId = offerId;\n product.Title = FirstString(item, \"title\", \"name\");\n product.SourceUrl = \"https://detail.1688.com/offer/\" + offerId + \".html\";\n product.PriceText = FirstString(item, \"price\", \"orginal_price\", \"salePrice\");\n product.PriceCny = ParseMoney(product.PriceText);\n product.ShopName = FirstString(item, \"nick\", \"seller_nick\", \"shop_name\");\n product.ShopUrl = FirstString(item, \"seller_url\", \"shop_url\");\n product.SalesCount = ParseInt(FirstString(item, \"sales\", \"volume\", \"sold\", \"sale_count\"));\n\n AddImage(product, FirstString(item, \"pic_url\", \"image\", \"main_image\"));\n JArray itemImages = FindFirstArray(item, new string[] { \"item_imgs.item_img\", \"images\", \"image_urls\" });\n if (itemImages != null)\n {\n for (int i = 0; i < itemImages.Count; i++)\n {\n JObject imageObject = itemImages[i] as JObject;\n string image = imageObject == null ? Convert.ToString(itemImages[i]) : FirstString(imageObject, \"url\", \"image\", \"img\");\n AddImage(product, image);\n }\n }\n\n JObject props = item[\"props_list\"] as JObject;\n if (props != null)\n {\n foreach (JProperty property in props.Properties())\n {\n string key = property.Name;\n int colon = key.IndexOf(':');\n if (colon >= 0 && colon + 1 < key.Length)\n {\n key = key.Substring(colon + 1);\n }\n\n if (!product.Attributes.ContainsKey(key))\n {\n product.Attributes[key] = Convert.ToString(property.Value);\n }\n }\n }\n\n return product;\n }\n\n private static JObject BuildOzonImportItem(SourceProduct product, SourcingOptions options)\n {\n decimal multiplier = options.PriceMultiplier <= 0 ? 2.2m : options.PriceMultiplier;\n string currency = string.IsNullOrEmpty(options.CurrencyCode) ? \"CNY\" : options.CurrencyCode;\n decimal price = string.Equals(currency, \"RUB\", StringComparison.OrdinalIgnoreCase)\n ? Math.Ceiling(Math.Max(1m, product.PriceCny) * (options.RubPerCny <= 0 ? 12.5m : options.RubPerCny) * multiplier)\n : Math.Ceiling(Math.Max(1m, product.PriceCny) * multiplier);\n string offerId = \"LZ1688-\" + SafeOfferId(product.OfferId);\n string primaryImage = !string.IsNullOrEmpty(product.MainImage) ? product.MainImage : (product.Images.Count > 0 ? product.Images[0] : string.Empty);\n string title = !string.IsNullOrEmpty(product.RussianTitle) ? product.RussianTitle : product.Title;\n string description = !string.IsNullOrEmpty(product.RussianDescription)\n ? product.RussianDescription\n : CleanPublicText(product.Title) + \"\\nИсточник: \" + product.SourceUrl;\n\n JObject item = new JObject();\n item[\"description_category_id\"] = options.OzonCategoryId;\n item[\"type_id\"] = options.OzonTypeId;\n item[\"name\"] = Truncate(CleanPublicText(title), 500);\n item[\"offer_id\"] = offerId;\n item[\"barcode\"] = string.Empty;\n item[\"price\"] = price.ToString(\"0\");\n item[\"old_price\"] = Math.Ceiling(price * 1.25m).ToString(\"0\");\n item[\"currency_code\"] = currency;\n item[\"vat\"] = string.IsNullOrEmpty(options.Vat) ? \"0\" : options.Vat;\n item[\"height\"] = 100;\n item[\"depth\"] = 100;\n item[\"width\"] = 100;\n item[\"dimension_unit\"] = \"mm\";\n item[\"weight\"] = 500;\n item[\"weight_unit\"] = \"g\";\n item[\"primary_image\"] = primaryImage;\n\n JArray images = new JArray();\n for (int i = 0; i < product.Images.Count; i++)\n {\n if (product.Images[i] != primaryImage)\n {\n images.Add(product.Images[i]);\n }\n }\n item[\"images\"] = images;\n\n JArray attributes = new JArray();\n foreach (KeyValuePair pair in product.OzonAttributes)\n {\n if (pair.Key <= 0 || string.IsNullOrEmpty(pair.Value))\n {\n continue;\n }\n\n JObject attr = new JObject();\n attr[\"id\"] = pair.Key;\n JArray values = new JArray();\n values.Add(new JObject(new JProperty(\"value\", Truncate(CleanPublicText(pair.Value), 900))));\n attr[\"values\"] = values;\n attributes.Add(attr);\n }\n\n item[\"attributes\"] = attributes;\n item[\"description\"] = Truncate(CleanPublicText(description), 3000);\n return item;\n }\n\n private static string PostOzonJson(string path, string json, string clientId, string apiKey)\n {\n EnsureModernTls();\n string url = OzonSellerApiBaseUrl + path;\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);\n request.Method = \"POST\";\n request.ContentType = \"application/json\";\n request.Accept = \"application/json\";\n request.Timeout = 60000;\n request.Headers[\"Client-Id\"] = clientId;\n request.Headers[\"Api-Key\"] = apiKey;\n\n byte[] body = Encoding.UTF8.GetBytes(json);\n request.ContentLength = body.Length;\n using (Stream requestStream = request.GetRequestStream())\n {\n requestStream.Write(body, 0, body.Length);\n }\n\n return ReadResponse(request);\n }\n\n private static string PostDeepSeekJson(string json, string apiKey)\n {\n EnsureModernTls();\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(DeepSeekChatEndpoint);\n request.Method = \"POST\";\n request.ContentType = \"application/json\";\n request.Accept = \"application/json\";\n request.Timeout = 45000;\n request.Headers[\"Authorization\"] = \"Bearer \" + apiKey;\n\n byte[] body = Encoding.UTF8.GetBytes(json);\n request.ContentLength = body.Length;\n using (Stream requestStream = request.GetRequestStream())\n {\n requestStream.Write(body, 0, body.Length);\n }\n\n return ReadResponse(request);\n }\n\n private static string GetJson(string url)\n {\n EnsureModernTls();\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);\n request.Method = \"GET\";\n request.Accept = \"application/json\";\n request.UserAgent = \"OZON-PILOT/1.0\";\n request.Timeout = 45000;\n return ReadResponse(request);\n }\n\n private static void EnsureModernTls()\n {\n if (_tlsInitialized)\n {\n return;\n }\n\n // TLS 1.2 is required by Ozon Seller API. .NET 4.0 may not expose the enum names,\n // so use numeric values while still allowing older endpoints used by legacy suppliers.\n const int tls10 = 192;\n const int tls11 = 768;\n const int tls12 = 3072;\n ServicePointManager.SecurityProtocol =\n (SecurityProtocolType)(tls10 | tls11 | tls12);\n ServicePointManager.Expect100Continue = false;\n ServicePointManager.ServerCertificateValidationCallback =\n delegate { return true; };\n _tlsInitialized = true;\n }\n\n private static string ReadResponse(HttpWebRequest request)\n {\n try\n {\n using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())\n using (Stream stream = response.GetResponseStream())\n using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))\n {\n return reader.ReadToEnd();\n }\n }\n catch (WebException ex)\n {\n string detail = ex.Message;\n if (ex.Response != null)\n {\n using (Stream stream = ex.Response.GetResponseStream())\n using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))\n {\n detail = reader.ReadToEnd();\n }\n }\n\n throw new InvalidOperationException(detail, ex);\n }\n }\n\n private static string NormalizeProvider(string value)\n {\n string provider = string.IsNullOrEmpty(value) ? \"onebound\" : value.Trim().ToLowerInvariant();\n return provider == \"dingdanxia\" ? \"dingdanxia\" : \"onebound\";\n }\n\n private static void AddImage(SourceProduct product, string image)\n {\n if (string.IsNullOrEmpty(image))\n {\n return;\n }\n\n if (string.IsNullOrEmpty(product.MainImage))\n {\n product.MainImage = image;\n }\n\n if (!product.Images.Contains(image))\n {\n product.Images.Add(image);\n }\n }\n\n private static JArray FindFirstArray(JObject obj, string[] paths)\n {\n for (int i = 0; i < paths.Length; i++)\n {\n JToken token = obj.SelectToken(paths[i]);\n if (token is JArray)\n {\n return (JArray)token;\n }\n }\n\n return null;\n }\n\n private static JObject FirstObject(JObject obj, params string[] names)\n {\n for (int i = 0; i < names.Length; i++)\n {\n JObject child = obj[names[i]] as JObject;\n if (child != null)\n {\n return child;\n }\n }\n\n return null;\n }\n\n private static string FirstString(JObject obj, params string[] names)\n {\n for (int i = 0; i < names.Length; i++)\n {\n JToken token = obj[names[i]];\n if (token != null && token.Type != JTokenType.Null)\n {\n string text = Convert.ToString(token);\n if (!string.IsNullOrEmpty(text))\n {\n return text.Trim();\n }\n }\n }\n\n return string.Empty;\n }\n\n private static decimal ParseMoney(string text)\n {\n if (string.IsNullOrEmpty(text))\n {\n return 0m;\n }\n\n StringBuilder builder = new StringBuilder();\n bool dot = false;\n for (int i = 0; i < text.Length; i++)\n {\n char c = text[i];\n if (char.IsDigit(c))\n {\n builder.Append(c);\n }\n else if (c == '.' && !dot)\n {\n builder.Append(c);\n dot = true;\n }\n else if (builder.Length > 0)\n {\n break;\n }\n }\n\n decimal value;\n return decimal.TryParse(builder.ToString(), out value) ? value : 0m;\n }\n\n private static int ParseInt(string text)\n {\n int value;\n return int.TryParse(text, out value) ? value : 0;\n }\n\n private static string SafeOfferId(string value)\n {\n if (string.IsNullOrEmpty(value))\n {\n return Guid.NewGuid().ToString(\"N\").Substring(0, 12);\n }\n\n StringBuilder builder = new StringBuilder();\n for (int i = 0; i < value.Length; i++)\n {\n char c = value[i];\n if (char.IsLetterOrDigit(c) || c == '-' || c == '_')\n {\n builder.Append(c);\n }\n }\n\n return builder.Length == 0 ? Guid.NewGuid().ToString(\"N\").Substring(0, 12) : builder.ToString();\n }\n\n private static string CleanPublicText(string value)\n {\n string text = value ?? string.Empty;\n text = text.Replace(\"Ozon hot\", string.Empty).Replace(\"1688 hot\", string.Empty);\n return WebUtility.HtmlDecode(text).Replace(\"\\r\", \" \").Replace(\"\\n\", \" \").Replace(\"\\t\", \" \").Trim();\n }\n\n private static string ExtractJsonObject(string value)\n {\n if (string.IsNullOrEmpty(value))\n {\n return \"{}\";\n }\n\n int start = value.IndexOf('{');\n int end = value.LastIndexOf('}');\n if (start >= 0 && end > start)\n {\n return value.Substring(start, end - start + 1);\n }\n\n return value;\n }\n\n private static string Truncate(string value, int maxLength)\n {\n if (string.IsNullOrEmpty(value) || value.Length <= maxLength)\n {\n return value ?? string.Empty;\n }\n\n return value.Substring(0, maxLength);\n }\n }\n}\n"}, "a2b6a291275ab03984378c5ea861e74d8a4e86ad": {"t": 1785756825.209251, "n": 26599, "h": "d2a1eaf768024e2a", "c": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Net;\nusing System.Text;\nusing Newtonsoft.Json;\nusing Newtonsoft.Json.Linq;\n\nnamespace LitchiOzonRecovery\n{\n internal sealed class SourcingSeed\n {\n public string Keyword { get; set; }\n public string Category { get; set; }\n public string Why { get; set; }\n }\n\n internal sealed class SourcingOptions\n {\n public string Provider { get; set; }\n public string ApiKey { get; set; }\n public string ApiSecret { get; set; }\n public int PerKeywordLimit { get; set; }\n public int DetailLimit { get; set; }\n public decimal RubPerCny { get; set; }\n public long OzonCategoryId { get; set; }\n public long OzonTypeId { get; set; }\n public decimal PriceMultiplier { get; set; }\n public string CurrencyCode { get; set; }\n public string Vat { get; set; }\n }\n\n internal sealed class SourceProduct\n {\n public string OfferId { get; set; }\n public string Title { get; set; }\n public string SourceUrl { get; set; }\n public decimal PriceCny { get; set; }\n public string PriceText { get; set; }\n public int SalesCount { get; set; }\n public string ShopName { get; set; }\n public string ShopUrl { get; set; }\n public string MainImage { get; set; }\n public List Images { get; set; }\n public Dictionary Attributes { get; set; }\n public string Keyword { get; set; }\n public decimal Score { get; set; }\n public string Decision { get; set; }\n public string Reason { get; set; }\n\n public SourceProduct()\n {\n Images = new List();\n Attributes = new Dictionary();\n }\n }\n\n internal sealed class SourcingResult\n {\n public List Products { get; private set; }\n public List Logs { get; private set; }\n\n public SourcingResult()\n {\n Products = new List();\n Logs = new List();\n }\n }\n\n internal sealed class OzonImportResult\n {\n public bool Success { get; set; }\n public string TaskId { get; set; }\n public string RawResponse { get; set; }\n public string ErrorMessage { get; set; }\n }\n\n internal sealed class ProductAutomationService\n {\n private const string OneboundSearchEndpoint = \"https://api-gw.onebound.cn/1688/item_search/\";\n private const string OneboundDetailEndpoint = \"https://api-gw.onebound.cn/1688/item_get/\";\n private const string DingdanxiaSearchEndpoint = \"https://api.dingdanxia.com/1688/item_search\";\n private const string DingdanxiaDetailEndpoint = \"https://api.dingdanxia.com/1688/item_get\";\n private const string OzonSellerApiBaseUrl = \"https://api-seller.ozon.ru\";\n private static bool _tlsInitialized;\n\n public SourcingResult Collect1688Candidates(IList seeds, AppConfig config, SourcingOptions options)\n {\n SourcingResult result = new SourcingResult();\n if (seeds == null || seeds.Count == 0)\n {\n result.Logs.Add(\"No keyword seeds.\");\n return result;\n }\n\n Validate1688Options(options);\n int perKeywordLimit = options.PerKeywordLimit <= 0 ? 5 : options.PerKeywordLimit;\n int detailLimit = options.DetailLimit <= 0 ? 12 : options.DetailLimit;\n Dictionary byOfferId = new Dictionary();\n\n for (int i = 0; i < seeds.Count; i++)\n {\n SourcingSeed seed = seeds[i];\n if (seed == null || string.IsNullOrEmpty(seed.Keyword))\n {\n continue;\n }\n\n result.Logs.Add(\"Search 1688: \" + seed.Keyword);\n List found = Search1688(seed.Keyword, options, perKeywordLimit);\n result.Logs.Add(\" found \" + found.Count + \" cards\");\n\n for (int j = 0; j < found.Count; j++)\n {\n SourceProduct product = found[j];\n product.Keyword = seed.Keyword;\n if (string.IsNullOrEmpty(product.OfferId) || byOfferId.ContainsKey(product.OfferId))\n {\n continue;\n }\n\n byOfferId[product.OfferId] = product;\n }\n }\n\n List candidates = new List(byOfferId.Values);\n candidates.Sort(delegate(SourceProduct left, SourceProduct right)\n {\n return right.SalesCount.CompareTo(left.SalesCount);\n });\n\n int enrichCount = Math.Min(detailLimit, candidates.Count);\n for (int i = 0; i < enrichCount; i++)\n {\n try\n {\n SourceProduct detail = Get1688Detail(candidates[i].OfferId, options);\n MergeProduct(candidates[i], detail);\n }\n catch (Exception ex)\n {\n result.Logs.Add(\" detail failed \" + candidates[i].OfferId + \": \" + ex.Message);\n }\n }\n\n for (int i = 0; i < candidates.Count; i++)\n {\n ScoreCandidate(candidates[i], config);\n }\n\n candidates.Sort(delegate(SourceProduct left, SourceProduct right)\n {\n int decision = DecisionRank(right.Decision).CompareTo(DecisionRank(left.Decision));\n if (decision != 0) return decision;\n return right.Score.CompareTo(left.Score);\n });\n\n result.Products.AddRange(candidates);\n result.Logs.Add(\"Final candidates: \" + result.Products.Count);\n return result;\n }\n\n public OzonImportResult UploadToOzon(IList products, SourcingOptions options, string clientId, string apiKey)\n {\n if (products == null || products.Count == 0)\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"No products selected.\" };\n }\n\n if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(apiKey))\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"Ozon Client-Id and Api-Key are required.\" };\n }\n\n if (options.OzonCategoryId <= 0 || options.OzonTypeId <= 0)\n {\n return new OzonImportResult { Success = false, ErrorMessage = \"Ozon category id and type id are required.\" };\n }\n\n JArray items = new JArray();\n for (int i = 0; i < products.Count; i++)\n {\n SourceProduct product = products[i];\n if (product == null)\n {\n continue;\n }\n\n items.Add(BuildOzonImportItem(product, options));\n }\n\n JObject payload = new JObject();\n payload[\"items\"] = items;\n string response = PostOzonJson(\"/v3/product/import\", payload.ToString(Formatting.None), clientId, apiKey);\n JObject parsed = JObject.Parse(response);\n string taskId = Convert.ToString(parsed.SelectToken(\"result.task_id\") ?? parsed.SelectToken(\"task_id\") ?? string.Empty);\n return new OzonImportResult\n {\n Success = true,\n TaskId = taskId,\n RawResponse = response\n };\n }\n\n public string GetOzonImportInfo(string taskId, string clientId, string apiKey)\n {\n JObject payload = new JObject();\n payload[\"task_id\"] = taskId;\n return PostOzonJson(\"/v1/product/import/info\", payload.ToString(Formatting.None), clientId, apiKey);\n }\n\n public void ExportCandidates(string path, IList products)\n {\n JArray array = JArray.FromObject(products ?? new List());\n File.WriteAllText(path, array.ToString(Formatting.Indented), new UTF8Encoding(false));\n }\n\n private static void Validate1688Options(SourcingOptions options)\n {\n if (options == null)\n {\n throw new ArgumentNullException(\"options\");\n }\n\n if (string.IsNullOrEmpty(options.ApiKey))\n {\n throw new InvalidOperationException(\"1688 API key is required.\");\n }\n }\n\n private static int DecisionRank(string decision)\n {\n if (string.Equals(decision, \"Go\", StringComparison.OrdinalIgnoreCase)) return 3;\n if (string.Equals(decision, \"Watch\", StringComparison.OrdinalIgnoreCase)) return 2;\n return 1;\n }\n\n private static void ScoreCandidate(SourceProduct product, AppConfig config)\n {\n decimal score = 0m;\n List reasons = new List();\n\n if (product.PriceCny > 0)\n {\n score += 20m;\n if (config != null && config.IsFilterGmPrice && product.PriceCny < config.MinGmPirce)\n {\n reasons.Add(\"source price below configured minimum\");\n }\n }\n else\n {\n reasons.Add(\"missing source price\");\n }\n\n if (product.SalesCount > 0)\n {\n score += Math.Min(25m, product.SalesCount / 20m);\n }\n else\n {\n reasons.Add(\"missing sales signal\");\n }\n\n if (!string.IsNullOrEmpty(product.MainImage) || product.Images.Count > 0)\n {\n score += 20m;\n }\n else\n {\n reasons.Add(\"missing product image\");\n }\n\n if (product.Attributes.Count > 0)\n {\n score += Math.Min(15m, product.Attributes.Count * 2m);\n }\n\n if (!string.IsNullOrEmpty(product.ShopName))\n {\n score += 10m;\n }\n\n if (config != null && config.MinSaleNum > 0 && product.SalesCount > 0 && product.SalesCount < config.MinSaleNum)\n {\n reasons.Add(\"sales below configured minimum\");\n }\n\n product.Score = Math.Round(score, 2);\n product.Decision = reasons.Count == 0 && score >= 45m ? \"Go\" : (score >= 30m ? \"Watch\" : \"No-Go\");\n product.Reason = reasons.Count == 0 ? \"meets basic source, sales and content checks\" : string.Join(\"; \", reasons.ToArray());\n }\n\n private static void MergeProduct(SourceProduct target, SourceProduct detail)\n {\n if (target == null || detail == null)\n {\n return;\n }\n\n if (!string.IsNullOrEmpty(detail.Title)) target.Title = detail.Title;\n if (!string.IsNullOrEmpty(detail.SourceUrl)) target.SourceUrl = detail.SourceUrl;\n if (detail.PriceCny > 0) target.PriceCny = detail.PriceCny;\n if (!string.IsNullOrEmpty(detail.PriceText)) target.PriceText = detail.PriceText;\n if (detail.SalesCount > 0) target.SalesCount = detail.SalesCount;\n if (!string.IsNullOrEmpty(detail.ShopName)) target.ShopName = detail.ShopName;\n if (!string.IsNullOrEmpty(detail.ShopUrl)) target.ShopUrl = detail.ShopUrl;\n if (!string.IsNullOrEmpty(detail.MainImage)) target.MainImage = detail.MainImage;\n if (detail.Images.Count > 0) target.Images = detail.Images;\n\n foreach (KeyValuePair pair in detail.Attributes)\n {\n if (!target.Attributes.ContainsKey(pair.Key))\n {\n target.Attributes[pair.Key] = pair.Value;\n }\n }\n }\n\n private static List Search1688(string keyword, SourcingOptions options, int limit)\n {\n string provider = NormalizeProvider(options.Provider);\n string url = provider == \"dingdanxia\"\n ? DingdanxiaSearchEndpoint + \"?apikey=\" + Uri.EscapeDataString(options.ApiKey) + \"&q=\" + Uri.EscapeDataString(keyword) + \"&page=1\"\n : OneboundSearchEndpoint + \"?key=\" + Uri.EscapeDataString(options.ApiKey) + \"&secret=\" + Uri.EscapeDataString(options.ApiSecret ?? string.Empty) + \"&q=\" + Uri.EscapeDataString(keyword) + \"&page=1&sort=default&page_size=\" + limit;\n JObject json = JObject.Parse(GetJson(url));\n JArray items = FindFirstArray(json, new string[] { \"items.item\", \"result\", \"data\", \"items\" });\n List products = new List();\n\n if (items == null)\n {\n return products;\n }\n\n for (int i = 0; i < items.Count && products.Count < limit; i++)\n {\n JObject item = items[i] as JObject;\n if (item == null)\n {\n continue;\n }\n\n string offerId = FirstString(item, \"num_iid\", \"id\", \"offerId\", \"offer_id\");\n if (string.IsNullOrEmpty(offerId))\n {\n continue;\n }\n\n SourceProduct product = new SourceProduct();\n product.OfferId = offerId;\n product.Title = FirstString(item, \"title\", \"name\");\n product.SourceUrl = \"https://detail.1688.com/offer/\" + offerId + \".html\";\n product.PriceText = FirstString(item, \"price\", \"salePrice\", \"priceText\");\n product.PriceCny = ParseMoney(product.PriceText);\n product.MainImage = FirstString(item, \"pic_url\", \"image\", \"img\", \"picUrl\");\n if (!string.IsNullOrEmpty(product.MainImage)) product.Images.Add(product.MainImage);\n product.ShopName = FirstString(item, \"nick\", \"shop_name\", \"seller_nick\");\n product.ShopUrl = FirstString(item, \"seller_url\", \"shop_url\");\n product.SalesCount = ParseInt(FirstString(item, \"sales\", \"volume\", \"sold\", \"sale_count\"));\n products.Add(product);\n }\n\n return products;\n }\n\n private static SourceProduct Get1688Detail(string offerId, SourcingOptions options)\n {\n string provider = NormalizeProvider(options.Provider);\n string url = provider == \"dingdanxia\"\n ? DingdanxiaDetailEndpoint + \"?apikey=\" + Uri.EscapeDataString(options.ApiKey) + \"&num_iid=\" + Uri.EscapeDataString(offerId)\n : OneboundDetailEndpoint + \"?key=\" + Uri.EscapeDataString(options.ApiKey) + \"&secret=\" + Uri.EscapeDataString(options.ApiSecret ?? string.Empty) + \"&num_iid=\" + Uri.EscapeDataString(offerId) + \"&lang=zh-CN\";\n JObject json = JObject.Parse(GetJson(url));\n JObject item = FirstObject(json, \"item\", \"result\", \"data\");\n if (item == null)\n {\n return null;\n }\n\n SourceProduct product = new SourceProduct();\n product.OfferId = offerId;\n product.Title = FirstString(item, \"title\", \"name\");\n product.SourceUrl = \"https://detail.1688.com/offer/\" + offerId + \".html\";\n product.PriceText = FirstString(item, \"price\", \"orginal_price\", \"salePrice\");\n product.PriceCny = ParseMoney(product.PriceText);\n product.ShopName = FirstString(item, \"nick\", \"seller_nick\", \"shop_name\");\n product.ShopUrl = FirstString(item, \"seller_url\", \"shop_url\");\n product.SalesCount = ParseInt(FirstString(item, \"sales\", \"volume\", \"sold\", \"sale_count\"));\n\n AddImage(product, FirstString(item, \"pic_url\", \"image\", \"main_image\"));\n JArray itemImages = FindFirstArray(item, new string[] { \"item_imgs.item_img\", \"images\", \"image_urls\" });\n if (itemImages != null)\n {\n for (int i = 0; i < itemImages.Count; i++)\n {\n JObject imageObject = itemImages[i] as JObject;\n string image = imageObject == null ? Convert.ToString(itemImages[i]) : FirstString(imageObject, \"url\", \"image\", \"img\");\n AddImage(product, image);\n }\n }\n\n JObject props = item[\"props_list\"] as JObject;\n if (props != null)\n {\n foreach (JProperty property in props.Properties())\n {\n string key = property.Name;\n int colon = key.IndexOf(':');\n if (colon >= 0 && colon + 1 < key.Length)\n {\n key = key.Substring(colon + 1);\n }\n\n if (!product.Attributes.ContainsKey(key))\n {\n product.Attributes[key] = Convert.ToString(property.Value);\n }\n }\n }\n\n return product;\n }\n\n private static JObject BuildOzonImportItem(SourceProduct product, SourcingOptions options)\n {\n decimal rubPerCny = options.RubPerCny <= 0 ? 12.5m : options.RubPerCny;\n decimal multiplier = options.PriceMultiplier <= 0 ? 2.2m : options.PriceMultiplier;\n decimal priceRub = Math.Ceiling(Math.Max(1m, product.PriceCny) * rubPerCny * multiplier);\n string offerId = \"LZ1688-\" + SafeOfferId(product.OfferId);\n string primaryImage = !string.IsNullOrEmpty(product.MainImage) ? product.MainImage : (product.Images.Count > 0 ? product.Images[0] : string.Empty);\n\n JObject item = new JObject();\n item[\"description_category_id\"] = options.OzonCategoryId;\n item[\"type_id\"] = options.OzonTypeId;\n item[\"name\"] = Truncate(CleanPublicText(product.Title), 500);\n item[\"offer_id\"] = offerId;\n item[\"barcode\"] = string.Empty;\n item[\"price\"] = priceRub.ToString(\"0\");\n item[\"old_price\"] = Math.Ceiling(priceRub * 1.25m).ToString(\"0\");\n item[\"currency_code\"] = string.IsNullOrEmpty(options.CurrencyCode) ? \"RUB\" : options.CurrencyCode;\n item[\"vat\"] = string.IsNullOrEmpty(options.Vat) ? \"0\" : options.Vat;\n item[\"height\"] = 100;\n item[\"depth\"] = 100;\n item[\"width\"] = 100;\n item[\"dimension_unit\"] = \"mm\";\n item[\"weight\"] = 500;\n item[\"weight_unit\"] = \"g\";\n item[\"primary_image\"] = primaryImage;\n\n JArray images = new JArray();\n for (int i = 0; i < product.Images.Count; i++)\n {\n if (product.Images[i] != primaryImage)\n {\n images.Add(product.Images[i]);\n }\n }\n item[\"images\"] = images;\n\n item[\"attributes\"] = new JArray();\n\n if (!string.IsNullOrEmpty(product.SourceUrl))\n {\n item[\"description\"] = Truncate(CleanPublicText(product.Title) + \"\\nSource: \" + product.SourceUrl, 3000);\n }\n\n return item;\n }\n\n private static string PostOzonJson(string path, string json, string clientId, string apiKey)\n {\n EnsureModernTls();\n string url = OzonSellerApiBaseUrl + path;\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);\n request.Method = \"POST\";\n request.ContentType = \"application/json\";\n request.Accept = \"application/json\";\n request.Timeout = 60000;\n request.Headers[\"Client-Id\"] = clientId;\n request.Headers[\"Api-Key\"] = apiKey;\n\n byte[] body = Encoding.UTF8.GetBytes(json);\n request.ContentLength = body.Length;\n using (Stream requestStream = request.GetRequestStream())\n {\n requestStream.Write(body, 0, body.Length);\n }\n\n return ReadResponse(request);\n }\n\n private static string GetJson(string url)\n {\n EnsureModernTls();\n HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);\n request.Method = \"GET\";\n request.Accept = \"application/json\";\n request.UserAgent = \"OZON-PILOT/1.0\";\n request.Timeout = 45000;\n return ReadResponse(request);\n }\n\n private static void EnsureModernTls()\n {\n if (_tlsInitialized)\n {\n return;\n }\n\n // TLS 1.2 is required by Ozon Seller API. .NET 4.0 may not expose the enum names,\n // so use numeric values while still allowing older endpoints used by legacy suppliers.\n const int tls10 = 192;\n const int tls11 = 768;\n const int tls12 = 3072;\n ServicePointManager.SecurityProtocol =\n (SecurityProtocolType)(tls10 | tls11 | tls12);\n ServicePointManager.Expect100Continue = false;\n ServicePointManager.ServerCertificateValidationCallback =\n delegate { return true; };\n _tlsInitialized = true;\n }\n\n private static string ReadResponse(HttpWebRequest request)\n {\n try\n {\n using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())\n using (Stream stream = response.GetResponseStream())\n using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))\n {\n return reader.ReadToEnd();\n }\n }\n catch (WebException ex)\n {\n string detail = ex.Message;\n if (ex.Response != null)\n {\n using (Stream stream = ex.Response.GetResponseStream())\n using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))\n {\n detail = reader.ReadToEnd();\n }\n }\n\n throw new InvalidOperationException(detail, ex);\n }\n }\n\n private static string NormalizeProvider(string value)\n {\n string provider = string.IsNullOrEmpty(value) ? \"onebound\" : value.Trim().ToLowerInvariant();\n return provider == \"dingdanxia\" ? \"dingdanxia\" : \"onebound\";\n }\n\n private static void AddImage(SourceProduct product, string image)\n {\n if (string.IsNullOrEmpty(image))\n {\n return;\n }\n\n if (string.IsNullOrEmpty(product.MainImage))\n {\n product.MainImage = image;\n }\n\n if (!product.Images.Contains(image))\n {\n product.Images.Add(image);\n }\n }\n\n private static JArray FindFirstArray(JObject obj, string[] paths)\n {\n for (int i = 0; i < paths.Length; i++)\n {\n JToken token = obj.SelectToken(paths[i]);\n if (token is JArray)\n {\n return (JArray)token;\n }\n }\n\n return null;\n }\n\n private static JObject FirstObject(JObject obj, params string[] names)\n {\n for (int i = 0; i < names.Length; i++)\n {\n JObject child = obj[names[i]] as JObject;\n if (child != null)\n {\n return child;\n }\n }\n\n return null;\n }\n\n private static string FirstString(JObject obj, params string[] names)\n {\n for (int i = 0; i < names.Length; i++)\n {\n JToken token = obj[names[i]];\n if (token != null && token.Type != JTokenType.Null)\n {\n string text = Convert.ToString(token);\n if (!string.IsNullOrEmpty(text))\n {\n return text.Trim();\n }\n }\n }\n\n return string.Empty;\n }\n\n private static decimal ParseMoney(string text)\n {\n if (string.IsNullOrEmpty(text))\n {\n return 0m;\n }\n\n StringBuilder builder = new StringBuilder();\n bool dot = false;\n for (int i = 0; i < text.Length; i++)\n {\n char c = text[i];\n if (char.IsDigit(c))\n {\n builder.Append(c);\n }\n else if (c == '.' && !dot)\n {\n builder.Append(c);\n dot = true;\n }\n else if (builder.Length > 0)\n {\n break;\n }\n }\n\n decimal value;\n return decimal.TryParse(builder.ToString(), out value) ? value : 0m;\n }\n\n private static int ParseInt(string text)\n {\n int value;\n return int.TryParse(text, out value) ? value : 0;\n }\n\n private static string SafeOfferId(string value)\n {\n if (string.IsNullOrEmpty(value))\n {\n return Guid.NewGuid().ToString(\"N\").Substring(0, 12);\n }\n\n StringBuilder builder = new StringBuilder();\n for (int i = 0; i < value.Length; i++)\n {\n char c = value[i];\n if (char.IsLetterOrDigit(c) || c == '-' || c == '_')\n {\n builder.Append(c);\n }\n }\n\n return builder.Length == 0 ? Guid.NewGuid().ToString(\"N\").Substring(0, 12) : builder.ToString();\n }\n\n private static string CleanPublicText(string value)\n {\n string text = value ?? string.Empty;\n text = text.Replace(\"Ozon hot\", string.Empty).Replace(\"1688 hot\", string.Empty);\n return text.Trim();\n }\n\n private static string Truncate(string value, int maxLength)\n {\n if (string.IsNullOrEmpty(value) || value.Length <= maxLength)\n {\n return value ?? string.Empty;\n }\n\n return value.Substring(0, maxLength);\n }\n }\n}\n"}}}}