'use strict'; const crypto = require('crypto'); const fs = require('fs/promises'); const path = require('path'); const dayjs = require('dayjs'); const { chromium } = require('playwright'); const { v4: uuidv4 } = require('uuid'); const planningDatabase = require('./planningDatabase'); const { ensurePlanningCanonicalSchemas, invalidatePlanningRuntimeCache, } = require('./planningUploadService'); const TARGET_BASE_URL = 'https://hsds.ecbis.cn'; const HSDS_API_BASE_URL = 'https://biplugs.ecbis.cn/prod'; const LOGIN_URL = `${TARGET_BASE_URL}/login`; const STORAGE_STATE_PATH = path.resolve(__dirname, '../../runtime/hsds/storage-state/market-insight.json'); const AES_KEY = 'xishuiwangluoMDF'; const TABLE_NAME = 'planning_category_demand_indices'; const CHECKPOINT_TABLE_NAME = 'planning_category_demand_indices_sync_checkpoints'; const HOT_SEARCH_WORD_TABLE_NAME = 'planning_category_hot_search_words'; const RISING_WORD_TABLE_NAME = 'planning_category_rising_words'; const DEMAND_PRODUCT_STYLE_TABLE_NAME = 'planning_demand_product_styles'; const PLATFORM = 'TX'; const DEFAULT_PRIMARY_CATEGORY_NAME = '住宅家具'; const DEFAULT_CATEGORY_LIMIT = 0; const DEFAULT_HISTORY_DAYS = 45; const DEFAULT_REQUEST_TIMEOUT_MS = 30000; const DEFAULT_REQUEST_DELAY_MS = 3500; const DEFAULT_REQUEST_MAX_ATTEMPTS = 8; const DEFAULT_REQUEST_RETRY_DELAY_MS = 15000; const MAX_INSERT_CHUNK_SIZE = 500; const SOURCE_FILE_NAME = 'sync_hsds_category_demand_indices.js'; const SOURCE_SHEET_TYPE = 'category_demand_indices'; const KEYWORD_SNAPSHOT_SOURCE_FILE_NAME = 'sync_hsds_category_keyword_snapshots.js'; const UPLOADED_BY_USERNAME = 'system'; const UPLOADED_BY_NAME = '系统同步'; const DEMAND_INDEX_TABLE_COLUMNS = Object.freeze([ { name: 'upload_batch_id', sql: 'VARCHAR(36) NOT NULL' }, { name: 'source_file_name', sql: 'VARCHAR(255) NOT NULL' }, { name: 'source_sheet_name', sql: 'VARCHAR(100) NOT NULL' }, { name: 'source_sheet_type', sql: 'VARCHAR(64) NOT NULL' }, { name: 'source_row_number', sql: 'INT NOT NULL' }, { name: 'uploaded_by_user_id', sql: 'BIGINT NULL' }, { name: 'uploaded_by_username', sql: 'VARCHAR(100) NULL' }, { name: 'uploaded_by_name', sql: 'VARCHAR(100) NULL' }, { name: 'uploaded_department_id', sql: 'BIGINT NULL' }, { name: 'uploaded_department_name', sql: 'VARCHAR(100) NULL' }, { name: 'raw_data', sql: 'JSON NULL' }, { name: 'row_fingerprint', sql: "CHAR(64) NULL COMMENT '企划模板内容指纹'" }, { name: 'stat_date_key', sql: "TEXT NULL COMMENT '来源表头: 日期'" }, { name: 'platform', sql: "TEXT NULL COMMENT '来源表头: 平台'" }, { name: 'primary_category_name', sql: "TEXT NULL COMMENT '来源表头: 一级品类'" }, { name: 'secondary_category_name', sql: "TEXT NULL COMMENT '来源表头: 二级品类'" }, { name: 'tertiary_category_name', sql: "TEXT NULL COMMENT '来源表头: 三级品类'" }, { name: 'market_category_name', sql: "TEXT NULL COMMENT '来源表头: 分类名称'" }, { name: 'search_keyword', sql: "TEXT NULL COMMENT '来源表头: 搜索词'" }, { name: 'root_word', sql: "TEXT NULL COMMENT '来源表头: 词根'" }, { name: 'search_index', sql: "TEXT NULL COMMENT '来源表头: 搜索指数'" }, { name: 'click_index', sql: "TEXT NULL COMMENT '来源表头: 点击指数'" }, { name: 'pay_index', sql: "TEXT NULL COMMENT '来源表头: 支付指数'" }, { name: 'click_rate', sql: "TEXT NULL COMMENT '来源表头: 点击率'" }, { name: 'pay_conversion_rate', sql: "TEXT NULL COMMENT '来源表头: 支付转化率'" }, { name: 'search_index_mom', sql: "TEXT NULL COMMENT '来源表头: 搜索指数环比'" }, { name: 'click_index_mom', sql: "TEXT NULL COMMENT '来源表头: 点击指数环比'" }, { name: 'pay_index_mom', sql: "TEXT NULL COMMENT '来源表头: 支付指数环比'" }, { name: 'click_rate_mom', sql: "TEXT NULL COMMENT '来源表头: 点击率环比'" }, { name: 'pay_conversion_rate_mom', sql: "TEXT NULL COMMENT '来源表头: 支付转化率环比'" }, ]); const DEMAND_INDEX_TABLE_INDEXES = Object.freeze([ { name: 'idx_batch_id', sql: 'ADD KEY idx_batch_id (`upload_batch_id`)' }, { name: 'idx_sheet_type', sql: 'ADD KEY idx_sheet_type (`source_sheet_type`)' }, { name: 'idx_row_fingerprint', sql: 'ADD KEY idx_row_fingerprint (`row_fingerprint`)' }, { name: 'idx_created_at', sql: 'ADD KEY idx_created_at (`created_at`)' }, { name: 'idx_category_date', sql: 'ADD KEY idx_category_date (`platform`(16), `primary_category_name`(100), `secondary_category_name`(100), `tertiary_category_name`(100), `stat_date_key`(8))', }, { name: 'idx_keyword_date', sql: 'ADD KEY idx_keyword_date (`search_keyword`(191), `stat_date_key`(8))', }, ]); let demandIndexSchemaReady = false; let demandIndexCheckpointSchemaReady = false; let demandProductStyleSchemaReady = false; function normalizeText(value, fallback = '') { const text = value === undefined || value === null ? '' : String(value).trim(); return text || fallback; } function normalizeNullableText(value) { const text = normalizeText(value); if (!text || text === '--' || text.toLowerCase() === 'null') { return null; } return text; } function normalizeDateKey(value) { const text = normalizeText(value).replace(/[^0-9]/g, '').slice(0, 8); return text.length === 8 ? text : ''; } function normalizeMetricText(value) { const text = normalizeNullableText(value); return text ? text.replace(/,/g, '') : null; } function normalizeBooleanEnv(value, fallback) { if (value === undefined || value === null || String(value).trim() === '') { return fallback; } const normalized = String(value).trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalized)) { return true; } if (['0', 'false', 'no', 'off'].includes(normalized)) { return false; } return fallback; } function normalizeInteger(value, fallback = 0) { const numeric = Number.parseInt(value, 10); return Number.isFinite(numeric) ? numeric : fallback; } function normalizeLeafFlag(value) { if (value === true || value === 1) return true; const text = normalizeText(value).toUpperCase(); return text === 'Y' || text === '1' || text === 'TRUE'; } function escapeIdentifier(value) { return `\`${String(value).replace(/`/g, '``')}\``; } async function getExistingTableColumns(connection, tableName) { const [rows] = await connection.query( `SELECT column_name AS columnName FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ?`, [tableName] ); return new Set(rows.map((row) => normalizeText(row.columnName)).filter(Boolean)); } async function getExistingTableIndexes(connection, tableName) { const [rows] = await connection.query( `SELECT DISTINCT index_name AS indexName FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ?`, [tableName] ); return new Set(rows.map((row) => normalizeText(row.indexName)).filter(Boolean)); } async function ensureDemandIndexTableSchema() { if (demandIndexSchemaReady) { return; } await planningDatabase.withTransaction(async (connection) => { await connection.query( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(TABLE_NAME)} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ${DEMAND_INDEX_TABLE_COLUMNS.map((column) => `${escapeIdentifier(column.name)} ${column.sql}`).join(',\n ')}, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_batch_id (${escapeIdentifier('upload_batch_id')}), KEY idx_sheet_type (${escapeIdentifier('source_sheet_type')}), KEY idx_row_fingerprint (${escapeIdentifier('row_fingerprint')}), KEY idx_created_at (${escapeIdentifier('created_at')}), KEY idx_category_date (${escapeIdentifier('platform')}(16), ${escapeIdentifier('primary_category_name')}(100), ${escapeIdentifier('secondary_category_name')}(100), ${escapeIdentifier('tertiary_category_name')}(100), ${escapeIdentifier('stat_date_key')}(8)), KEY idx_keyword_date (${escapeIdentifier('search_keyword')}(191), ${escapeIdentifier('stat_date_key')}(8)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划模板原始数据:品类需求指数'` ); const existingColumns = await getExistingTableColumns(connection, TABLE_NAME); for (const column of DEMAND_INDEX_TABLE_COLUMNS) { if (existingColumns.has(column.name)) { continue; } await connection.query( `ALTER TABLE ${escapeIdentifier(TABLE_NAME)} ADD COLUMN ${escapeIdentifier(column.name)} ${column.sql}` ); } if (!existingColumns.has('created_at')) { await connection.query( `ALTER TABLE ${escapeIdentifier(TABLE_NAME)} ADD COLUMN ${escapeIdentifier('created_at')} DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` ); } const existingIndexes = await getExistingTableIndexes(connection, TABLE_NAME); for (const index of DEMAND_INDEX_TABLE_INDEXES) { if (existingIndexes.has(index.name)) { continue; } await connection.query( `ALTER TABLE ${escapeIdentifier(TABLE_NAME)} ${index.sql}` ); } }); demandIndexSchemaReady = true; } async function ensureDemandIndexCheckpointSchema() { if (demandIndexCheckpointSchemaReady) { return; } await planningDatabase.withTransaction(async (connection) => { await connection.query( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(CHECKPOINT_TABLE_NAME)} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, platform VARCHAR(16) NOT NULL, primary_category_name VARCHAR(100) NOT NULL, secondary_category_name VARCHAR(150) NOT NULL DEFAULT '', tertiary_category_name VARCHAR(150) NOT NULL DEFAULT '', stat_date_key CHAR(8) NOT NULL, history_start_date_key CHAR(8) NOT NULL, history_end_date_key CHAR(8) NOT NULL, history_days INT NOT NULL DEFAULT 45, status ENUM('running', 'success', 'failed', 'skipped') NOT NULL DEFAULT 'running', row_count INT NOT NULL DEFAULT 0, source_batch_id VARCHAR(36) NULL, note VARCHAR(500) NULL, fetched_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_demand_checkpoint_scope ( platform, primary_category_name, secondary_category_name, tertiary_category_name, stat_date_key, history_start_date_key, history_end_date_key ), KEY idx_demand_checkpoint_status (status, stat_date_key), KEY idx_demand_checkpoint_updated_at (updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='HSDS 需求指数同步检查点'` ); }); demandIndexCheckpointSchemaReady = true; } async function ensureDemandProductStyleTableSchema() { if (demandProductStyleSchemaReady) { return; } await planningDatabase.withTransaction(async (connection) => { await connection.query( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(DEMAND_PRODUCT_STYLE_TABLE_NAME)} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, platform VARCHAR(16) NOT NULL DEFAULT 'TX', primary_category_name VARCHAR(100) NOT NULL, secondary_category_name VARCHAR(150) NOT NULL DEFAULT '', tertiary_category_name VARCHAR(150) NOT NULL DEFAULT '', market_category_name VARCHAR(500) NOT NULL, cate_id VARCHAR(64) NOT NULL DEFAULT '', root_word VARCHAR(191) NOT NULL, month_key CHAR(7) NOT NULL, dates_type VARCHAR(32) NOT NULL, rank_position INT NOT NULL DEFAULT 0, goods_id VARCHAR(100) NOT NULL DEFAULT '', goods_title VARCHAR(500) NOT NULL DEFAULT '', image_url TEXT NULL, shop_name VARCHAR(255) NULL, brand_name VARCHAR(255) NULL, sales_amount VARCHAR(64) NULL, sale_count VARCHAR(64) NULL, unit_price VARCHAR(64) NULL, visitor_count VARCHAR(64) NULL, conversion_rate VARCHAR(64) NULL, detail_url TEXT NULL, raw_data JSON NULL, row_fingerprint CHAR(64) NOT NULL, fetched_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_demand_product_scope ( platform, primary_category_name(80), secondary_category_name(100), tertiary_category_name(100), root_word(120), month_key, dates_type, rank_position ), KEY idx_demand_product_fingerprint (row_fingerprint), KEY idx_demand_product_fetched_at (fetched_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='HSDS 产品需求分析商品款式快照'` ); }); demandProductStyleSchemaReady = true; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function withTimeout(promise, timeoutMs, label = 'operation') { const ms = normalizeInteger(timeoutMs, 0); if (!ms) { return promise; } let timer = null; const timeout = new Promise((_, reject) => { timer = setTimeout(() => { reject(new Error(`${label} timed out after ${ms}ms`)); }, ms); }); try { return await Promise.race([promise, timeout]); } finally { if (timer) { clearTimeout(timer); } if (promise && typeof promise.catch === 'function') { promise.catch(() => {}); } } } function isHsdsRateLimitResponse(json) { return Number(json?.code) === 10006 || normalizeText(json?.content).includes('操作频率过快'); } function isHsdsOtherDeviceLoginResponse(json) { return Number(json?.code) === -7 || normalizeText(json?.content).includes('该账号已在其他设备登录'); } function isHsdsLoginExpiredResponse(json) { return Number(json?.code) === -1 || normalizeText(json?.content).includes('用户未登录或登录状态已过期'); } async function applyRequestDelay(context, options = {}) { const waitMs = Math.max(normalizeInteger(options.requestDelayMs, DEFAULT_REQUEST_DELAY_MS), 0); if (!waitMs) { return; } const lastRequestedAt = Number(context._hsdsLastRequestedAt || 0); const elapsedMs = lastRequestedAt ? Date.now() - lastRequestedAt : null; const remainingMs = elapsedMs === null ? waitMs : Math.max(waitMs - elapsedMs, 0); if (remainingMs > 0) { await sleep(remainingMs); } context._hsdsLastRequestedAt = Date.now(); } async function pathExists(filePath) { const target = normalizeText(filePath); if (!target) { return false; } try { await fs.access(target); return true; } catch (_error) { return false; } } async function ensureParentDirectory(filePath) { const target = normalizeText(filePath); if (!target) { return; } await fs.mkdir(path.dirname(target), { recursive: true }); } function isMissingBrowserExecutableError(error) { const message = normalizeText(error?.message).toLowerCase(); return message.includes('executable doesn\'t exist') || message.includes('executable does not exist'); } async function resolveFallbackBrowserExecutable() { const candidates = [ process.env.HSDS_BROWSER_EXECUTABLE, process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, '/snap/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', ]; for (const candidate of candidates) { if (await pathExists(candidate)) { return candidate; } } return ''; } function resolveRuntimeOptions(options = {}) { return { dryRun: Boolean(options.dryRun), sampleOnly: Boolean(options.sampleOnly), forceRefresh: Boolean(options.forceRefresh), headless: options.headless !== false, useStorageState: normalizeBooleanEnv( options.useStorageState ?? process.env.HSDS_DEMAND_SYNC_USE_STORAGE_STATE, true ), persistStorageState: normalizeBooleanEnv( options.persistStorageState ?? process.env.HSDS_DEMAND_SYNC_PERSIST_STORAGE_STATE, true ), storageStatePath: normalizeText( options.storageStatePath || process.env.HSDS_DEMAND_SYNC_STORAGE_STATE_PATH || STORAGE_STATE_PATH ), browserChannel: normalizeText( options.browserChannel || process.env.HSDS_DEMAND_SYNC_BROWSER_CHANNEL || (process.platform === 'darwin' ? 'chrome' : '') ), browserExecutable: normalizeText( options.browserExecutable || process.env.HSDS_DEMAND_SYNC_BROWSER_EXECUTABLE ), username: normalizeText( options.username || process.env.HSDS_DEMAND_SYNC_USERNAME || process.env.HSDS_USERNAME ), password: normalizeText( options.password || process.env.HSDS_DEMAND_SYNC_PASSWORD || process.env.HSDS_PASSWORD ), statDateKey: normalizeDateKey( options.statDateKey || process.env.HSDS_DEMAND_SYNC_STAT_DATE ), primaryCategoryName: normalizeText( options.primaryCategoryName || process.env.HSDS_DEMAND_SYNC_PRIMARY_CATEGORY || DEFAULT_PRIMARY_CATEGORY_NAME ), secondaryCategoryName: normalizeText( options.secondaryCategoryName || process.env.HSDS_DEMAND_SYNC_SECONDARY_CATEGORY ), tertiaryCategoryName: normalizeText( options.tertiaryCategoryName || process.env.HSDS_DEMAND_SYNC_TERTIARY_CATEGORY ), categoryLimit: Math.max( normalizeInteger( options.categoryLimit ?? process.env.HSDS_DEMAND_SYNC_CATEGORY_LIMIT, DEFAULT_CATEGORY_LIMIT ), 0 ), historyDays: Math.max( normalizeInteger( options.historyDays ?? process.env.HSDS_DEMAND_SYNC_HISTORY_DAYS, DEFAULT_HISTORY_DAYS ), 1 ), requestTimeoutMs: Math.max( normalizeInteger( options.requestTimeoutMs ?? process.env.HSDS_DEMAND_SYNC_REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS ), 1000 ), requestDelayMs: Math.max( normalizeInteger( options.requestDelayMs ?? process.env.HSDS_DEMAND_SYNC_REQUEST_DELAY_MS, DEFAULT_REQUEST_DELAY_MS ), 0 ), maxAttempts: Math.max( normalizeInteger( options.maxAttempts ?? process.env.HSDS_DEMAND_SYNC_MAX_ATTEMPTS, DEFAULT_REQUEST_MAX_ATTEMPTS ), 1 ), retryDelayMs: Math.max( normalizeInteger( options.retryDelayMs ?? process.env.HSDS_DEMAND_SYNC_RETRY_DELAY_MS, DEFAULT_REQUEST_RETRY_DELAY_MS ), 0 ), strictMetricCoverage: normalizeBooleanEnv( options.strictMetricCoverage ?? process.env.HSDS_DEMAND_SYNC_STRICT_METRIC_COVERAGE, true ), }; } function decryptPayload(payload) { if (typeof payload !== 'string') { return payload; } const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(AES_KEY, 'utf8'), null); decipher.setAutoPadding(true); let decrypted = decipher.update(payload, 'base64', 'utf8'); decrypted += decipher.final('utf8'); try { return JSON.parse(decrypted); } catch (_error) { return decrypted; } } function buildCategoryPathLabel(category = {}) { return [ normalizeText(category.primaryCategoryName), normalizeText(category.secondaryCategoryName), normalizeText(category.tertiaryCategoryName), ].filter(Boolean).join(' > '); } function buildMarketCategoryName(category = {}) { return buildCategoryPathLabel(category) || normalizeText(category.primaryCategoryName, DEFAULT_PRIMARY_CATEGORY_NAME); } function buildFingerprint(row = {}) { return crypto .createHash('sha256') .update([ normalizeText(row.stat_date_key), normalizeText(row.platform), normalizeText(row.primary_category_name), normalizeText(row.secondary_category_name), normalizeText(row.tertiary_category_name), normalizeText(row.market_category_name), normalizeText(row.search_keyword), normalizeText(row.root_word), normalizeText(row.search_index), normalizeText(row.click_index), normalizeText(row.pay_index), normalizeText(row.click_rate), normalizeText(row.pay_conversion_rate), ].join('||')) .digest('hex'); } function buildKeywordSnapshotFingerprint(row = {}) { return crypto .createHash('sha256') .update([ normalizeText(row.source_sheet_type), normalizeText(row.snapshot_period), normalizeText(row.market_category_name), normalizeText(row.search_keyword), normalizeText(row.search_growth_rate), normalizeText(row.search_popularity_band), normalizeText(row.click_rate), normalizeText(row.pay_conversion_rate), ].join('||')) .digest('hex'); } function resolveCategoryScopeParts(category = {}) { return { primaryCategoryName: normalizeText(category.primaryCategoryName || DEFAULT_PRIMARY_CATEGORY_NAME), secondaryCategoryName: normalizeText(category.secondaryCategoryName), tertiaryCategoryName: normalizeText(category.tertiaryCategoryName), }; } function computeHistoryStartDateKey(currentDateKey, historyDays) { const current = normalizeDateKey(currentDateKey); if (!current) { return ''; } return dayjs(current, 'YYYYMMDD') .subtract(Math.max(historyDays || DEFAULT_HISTORY_DAYS, 1) - 1, 'day') .format('YYYYMMDD'); } async function getSuccessfulCheckpoint(category = {}, statDateKey = '', historyStartDateKey = '', historyEndDateKey = '') { const scope = resolveCategoryScopeParts(category); const rows = await planningDatabase.query( `SELECT id, status, row_count AS rowCount, source_batch_id AS sourceBatchId, fetched_at AS fetchedAt, updated_at AS updatedAt, note FROM ${escapeIdentifier(CHECKPOINT_TABLE_NAME)} WHERE platform = ? AND primary_category_name = ? AND secondary_category_name = ? AND tertiary_category_name = ? AND stat_date_key = ? AND history_start_date_key = ? AND history_end_date_key = ? AND status = 'success' LIMIT 1`, [ PLATFORM, scope.primaryCategoryName, scope.secondaryCategoryName, scope.tertiaryCategoryName, normalizeDateKey(statDateKey), normalizeDateKey(historyStartDateKey), normalizeDateKey(historyEndDateKey), ] ); return rows[0] || null; } async function detectExistingCoverage(category = {}, historyStartDateKey = '', historyEndDateKey = '') { const scope = resolveCategoryScopeParts(category); const normalizedStartDateKey = normalizeDateKey(historyStartDateKey); const normalizedEndDateKey = normalizeDateKey(historyEndDateKey); const expectedMetricDateCount = normalizedStartDateKey && normalizedEndDateKey ? Math.max(dayjs(normalizedEndDateKey, 'YYYYMMDD').diff(dayjs(normalizedStartDateKey, 'YYYYMMDD'), 'day') + 1, 1) : 1; const rows = await planningDatabase.query( `SELECT COUNT(*) AS totalRows, MIN(stat_date_key) AS minDateKey, MAX(stat_date_key) AS maxDateKey, MAX(CASE WHEN stat_date_key = ? THEN 1 ELSE 0 END) AS hasLatestDate, SUM(CASE WHEN stat_date_key = ? THEN 1 ELSE 0 END) AS latestRowCount, SUM(CASE WHEN stat_date_key = ? AND click_index IS NOT NULL AND LENGTH(click_index) > 0 AND pay_index IS NOT NULL AND LENGTH(pay_index) > 0 THEN 1 ELSE 0 END) AS latestMetricRowCount, COUNT(DISTINCT CASE WHEN click_index IS NOT NULL AND LENGTH(click_index) > 0 AND pay_index IS NOT NULL AND LENGTH(pay_index) > 0 THEN stat_date_key ELSE NULL END) AS metricDateCount FROM ${escapeIdentifier(TABLE_NAME)} WHERE platform = ? AND primary_category_name = ? AND COALESCE(secondary_category_name, '') = ? AND COALESCE(tertiary_category_name, '') = ? AND stat_date_key BETWEEN ? AND ?`, [ normalizeDateKey(historyEndDateKey), normalizeDateKey(historyEndDateKey), normalizeDateKey(historyEndDateKey), PLATFORM, scope.primaryCategoryName, scope.secondaryCategoryName, scope.tertiaryCategoryName, normalizedStartDateKey, normalizedEndDateKey, ] ); const summary = rows[0] || {}; const totalRows = Number(summary.totalRows || 0); const minDateKey = normalizeDateKey(summary.minDateKey); const maxDateKey = normalizeDateKey(summary.maxDateKey); const hasLatestDate = Number(summary.hasLatestDate || 0) === 1; const latestRowCount = Number(summary.latestRowCount || 0); const latestMetricRowCount = Number(summary.latestMetricRowCount || 0); const metricDateCount = Number(summary.metricDateCount || 0); return { totalRows, minDateKey, maxDateKey, hasLatestDate, latestRowCount, latestMetricRowCount, metricDateCount, covered: totalRows > 0 && hasLatestDate && latestRowCount > 0 && latestMetricRowCount > 0 && metricDateCount >= expectedMetricDateCount && maxDateKey >= normalizedEndDateKey, }; } async function upsertDemandIndexCheckpoint(connection, payload = {}) { const scope = resolveCategoryScopeParts(payload.category); const values = { platform: PLATFORM, primary_category_name: scope.primaryCategoryName, secondary_category_name: scope.secondaryCategoryName, tertiary_category_name: scope.tertiaryCategoryName, stat_date_key: normalizeDateKey(payload.statDateKey), history_start_date_key: normalizeDateKey(payload.historyStartDateKey), history_end_date_key: normalizeDateKey(payload.historyEndDateKey), history_days: Math.max(normalizeInteger(payload.historyDays, DEFAULT_HISTORY_DAYS), 1), status: normalizeText(payload.status || 'success'), row_count: Math.max(normalizeInteger(payload.rowCount, 0), 0), source_batch_id: normalizeNullableText(payload.sourceBatchId), note: normalizeNullableText(payload.note), fetched_at: payload.fetchedAt || dayjs().format('YYYY-MM-DD HH:mm:ss'), }; await connection.query( `INSERT INTO ${escapeIdentifier(CHECKPOINT_TABLE_NAME)} ( platform, primary_category_name, secondary_category_name, tertiary_category_name, stat_date_key, history_start_date_key, history_end_date_key, history_days, status, row_count, source_batch_id, note, fetched_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE history_days = VALUES(history_days), status = VALUES(status), row_count = VALUES(row_count), source_batch_id = VALUES(source_batch_id), note = VALUES(note), fetched_at = VALUES(fetched_at)`, [ values.platform, values.primary_category_name, values.secondary_category_name, values.tertiary_category_name, values.stat_date_key, values.history_start_date_key, values.history_end_date_key, values.history_days, values.status, values.row_count, values.source_batch_id, values.note, values.fetched_at, ] ); } async function createBrowserContext(options = {}) { const launchOptions = { headless: options.headless !== false, }; if (options.browserChannel) { launchOptions.channel = options.browserChannel; } if (options.browserExecutable) { launchOptions.executablePath = options.browserExecutable; } let browser; try { browser = await chromium.launch(launchOptions); } catch (error) { if (!isMissingBrowserExecutableError(error)) { throw error; } const fallbackExecutable = await resolveFallbackBrowserExecutable(); if (!fallbackExecutable) { throw error; } browser = await chromium.launch({ headless: options.headless !== false, executablePath: fallbackExecutable, }); } const contextOptions = {}; if (options.useStorageState && options.storageStatePath && await pathExists(options.storageStatePath)) { contextOptions.storageState = options.storageStatePath; } const context = await browser.newContext(contextOptions); return { browser, context }; } async function saveStorageState(context, storageStatePath) { if (!storageStatePath) { return; } await ensureParentDirectory(storageStatePath); await context.storageState({ path: storageStatePath }); } async function isSessionUsable(context, requestTimeoutMs) { const page = await context.newPage(); try { await page.goto(`${TARGET_BASE_URL}/workTable/workTableHome`, { waitUntil: 'domcontentloaded', timeout: requestTimeoutMs, }); await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); const currentUrl = normalizeText(page.url()); if (currentUrl.includes('/login')) { return false; } const hasLoginInput = await page.getByPlaceholder('请输入账号').isVisible().catch(() => false); if (hasLoginInput) { return false; } return currentUrl.includes('/workTable/'); } catch (_error) { return false; } finally { await page.close().catch(() => {}); } } async function loginWithCredentials(context, options = {}) { const username = normalizeText(options.username); const password = normalizeText(options.password); if (!username || !password) { throw new Error('HSDS 账号或密码未配置,无法自动登录'); } const page = await context.newPage(); try { await page.goto(LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: options.requestTimeoutMs }); await page.locator('input[placeholder="请输入账号"]:visible').first().fill(username); await page.locator('input[placeholder="请输入密码"]:visible').first().fill(password); await page.locator('.el-checkbox__input:visible').last().click({ force: true }).catch(() => {}); await page.locator('div.x-button:visible').filter({ hasText: '登录' }).first().click(); const agreeButton = page.getByRole('button', { name: '同意并登录' }); if (await agreeButton.count()) { await agreeButton.click().catch(() => {}); } await page.waitForURL('**/workTable/workTableHome', { timeout: options.requestTimeoutMs }); } finally { await page.close().catch(() => {}); } const usable = await isSessionUsable(context, options.requestTimeoutMs); if (!usable) { throw new Error('HSDS 登录失败,请检查账号密码或 storage state 是否有效'); } } async function ensureAuthenticatedContext(options = {}) { const { browser, context } = await createBrowserContext(options); let authenticated = false; try { authenticated = await isSessionUsable(context, options.requestTimeoutMs); if (!authenticated) { await loginWithCredentials(context, options); authenticated = true; if (options.persistStorageState !== false && options.storageStatePath) { await saveStorageState(context, options.storageStatePath); } } return { browser, context, close: async () => { await context.close().catch(() => {}); await browser.close().catch(() => {}); }, }; } catch (error) { await context.close().catch(() => {}); await browser.close().catch(() => {}); throw error; } } function buildUrl(endpoint, query = {}) { const url = endpoint.startsWith('http') ? endpoint : `${HSDS_API_BASE_URL}${endpoint}`; const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(query || {})) { const normalized = normalizeText(value); if (normalized) { searchParams.set(key, normalized); } } const queryString = searchParams.toString(); return queryString ? `${url}?${queryString}` : url; } async function requestHsds(context, endpoint, options = {}) { const method = normalizeText(options.method || 'GET').toUpperCase(); const url = buildUrl(endpoint, options.query); const maxAttempts = Math.max(normalizeInteger(options.maxAttempts, DEFAULT_REQUEST_MAX_ATTEMPTS), 1); const retryDelayMs = Math.max(normalizeInteger(options.retryDelayMs, DEFAULT_REQUEST_RETRY_DELAY_MS), 0); let reloginAttempted = false; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { await applyRequestDelay(context, options); const requestOptions = { timeout: options.requestTimeoutMs, failOnStatusCode: false, }; if (method === 'POST') { requestOptions.data = options.data || {}; requestOptions.headers = { 'content-type': 'application/json;charset=UTF-8', }; } const response = await withTimeout( method === 'POST' ? context.request.post(url, requestOptions) : context.request.get(url, requestOptions), options.requestTimeoutMs, `HSDS ${method} ${endpoint}` ); const json = await withTimeout( response.json().catch(() => null), options.requestTimeoutMs, `HSDS ${method} ${endpoint} json` ); if (!response.ok()) { throw new Error(`HSDS 接口请求失败: ${method} ${url} HTTP ${response.status()}`.trim()); } if (json && json.success === true && Number(json.code) === 0) { return decryptPayload(json.data); } if (isHsdsLoginExpiredResponse(json)) { if (reloginAttempted) { throw new Error(`HSDS 登录状态失效且重登失败: ${method} ${url} code=${json?.code ?? 'unknown'} content=${json?.content || ''}`.trim()); } reloginAttempted = true; console.warn(`[HSDS] 登录状态失效,尝试重新登录后重试: ${endpoint}`); await loginWithCredentials(context, options); if (options.persistStorageState !== false && options.storageStatePath) { await saveStorageState(context, options.storageStatePath); } continue; } if (isHsdsOtherDeviceLoginResponse(json)) { throw new Error(`HSDS 账号被其他设备登录顶掉: ${method} ${url} code=${json?.code ?? 'unknown'} content=${json?.content || ''}`.trim()); } if (attempt < maxAttempts && isHsdsRateLimitResponse(json)) { const waitMs = retryDelayMs * attempt * attempt; console.warn(`[HSDS] 需求指数接口触发频率限制,${waitMs}ms 后重试: ${endpoint} (第 ${attempt}/${maxAttempts} 次)`); await sleep(waitMs); continue; } throw new Error(`HSDS 接口请求失败: ${method} ${url} status=${response.status()} code=${json?.code ?? 'unknown'} content=${json?.content || response.statusText()}`.trim()); } } function resolveRequestControlOptions(options = {}) { return { requestTimeoutMs: options.requestTimeoutMs, requestDelayMs: options.requestDelayMs, maxAttempts: options.maxAttempts, retryDelayMs: options.retryDelayMs, }; } async function fetchPrimaryCategory(context, options = {}) { const categories = await requestHsds(context, '/category/categoryFirstList', { query: { platform: PLATFORM }, ...resolveRequestControlOptions(options), }); const matched = (Array.isArray(categories) ? categories : []).find((item) => { return normalizeText(item.name) === options.primaryCategoryName; }); if (!matched) { throw new Error(`HSDS 未找到一级类目: ${options.primaryCategoryName}`); } return matched; } async function fetchCategoryTree(context, primaryCategoryId, options = {}) { const rows = await requestHsds(context, '/category/getCategoryById', { query: { platform: PLATFORM, categoryId: primaryCategoryId, isAuth: 'true', }, ...resolveRequestControlOptions(options), }); return Array.isArray(rows) ? rows : []; } function buildNodePath(nodeId, nodeMap) { const pathNodes = []; const visited = new Set(); let current = nodeMap.get(String(nodeId)); while (current && !visited.has(String(current.id))) { pathNodes.unshift(current); visited.add(String(current.id)); const parentId = normalizeText(current.pid); if (!parentId || parentId === '0') { break; } current = nodeMap.get(parentId); } return pathNodes; } function buildLeafCategories(primaryCategory, flatRows = [], options = {}) { const nodeMap = new Map(); for (const row of flatRows) { nodeMap.set(String(row.id), row); } const leaves = flatRows.filter((row) => normalizeLeafFlag(row.isLeaf)); const siblingLeafMap = new Map(); for (const leaf of leaves) { const parentId = normalizeText(leaf.pid || primaryCategory.id); if (!siblingLeafMap.has(parentId)) { siblingLeafMap.set(parentId, []); } siblingLeafMap.get(parentId).push(String(leaf.id)); } return leaves .map((leaf) => { const pathNodes = buildNodePath(leaf.id, nodeMap); const pathNames = pathNodes.map((item) => normalizeText(item.name)).filter(Boolean); const secondaryNode = pathNodes[1] || null; const tertiaryPathNames = pathNames.slice(2); const tertiaryNode = pathNodes.length >= 3 ? pathNodes[pathNodes.length - 1] : null; return { primaryCategoryId: normalizeText(primaryCategory.id), primaryCategoryName: normalizeText(primaryCategory.name), secondaryCategoryId: normalizeText(secondaryNode?.id), secondaryCategoryName: normalizeText(secondaryNode?.name), tertiaryCategoryId: normalizeText(tertiaryNode?.id), tertiaryCategoryName: normalizeText(tertiaryPathNames.join(' > ') || tertiaryNode?.name), categoryId: normalizeText(leaf.id), categoryName: normalizeText(leaf.name), marketCategoryName: pathNames.join(' > ') || normalizeText(primaryCategory.name), siblingCategoryIds: (siblingLeafMap.get(normalizeText(leaf.pid || primaryCategory.id)) || [String(leaf.id)]).join(','), }; }) .filter((row) => { if (options.secondaryCategoryName && row.secondaryCategoryName !== options.secondaryCategoryName) { return false; } if ( options.tertiaryCategoryName && row.tertiaryCategoryName !== options.tertiaryCategoryName && row.categoryName !== options.tertiaryCategoryName ) { return false; } return true; }); } async function fetchKeywordTime(context, categoryId, options = {}) { const payload = await requestHsds(context, '/demand/getKeywordAnalysisTime', { query: { platform: PLATFORM, categoryId, pluginType: 'demandInsight', }, ...resolveRequestControlOptions(options), }); return payload || {}; } async function fetchHotWords(context, categoryId, options = {}) { const date = normalizeText(options.hotWordDate || options.date || '7'); const datesType = normalizeText(options.hotWordDatesType || options.datesType || '7'); const isTimeChange = normalizeText( options.hotWordIsTimeChange ?? options.isTimeChange ?? (datesType === 'day' ? 'true' : 'false') ); const payload = await requestHsds(context, '/demand/hotWords', { query: { platform: PLATFORM, cateId: categoryId, date, datesType, isTimeChange, filterBrandKeyWord: 'false', }, ...resolveRequestControlOptions(options), }); return payload || {}; } async function fetchSoaringWords(context, categoryId, options = {}) { const date = normalizeText(options.hotWordDate || options.date || '7'); const datesType = normalizeText(options.hotWordDatesType || options.datesType || '7'); const payload = await requestHsds(context, '/demand/soaringWords', { method: 'POST', data: { platform: PLATFORM, cateId: categoryId, date, datesType, isTimeChange: 'false', filterBrandKeyWord: false, }, ...resolveRequestControlOptions(options), }); return payload || {}; } function resolveHsdsDemandDatesType(options = {}) { const raw = normalizeText(options.dateShortcut || options.windowType || options.datesType || options.historyDays || '7'); const normalized = raw.toLowerCase(); if (['7天', '7d', 'day7', '7'].includes(normalized)) return '7'; if (['14天', '14d', 'day14', '14'].includes(normalized)) return '14'; if (['30天', '30d', 'day30', '30'].includes(normalized)) return '30'; if (['日', 'day'].includes(normalized)) return 'day'; if (['周', 'week'].includes(normalized)) return 'week'; if (['月', 'month'].includes(normalized)) return 'month'; return '7'; } function resolveHsdsDemandMonth(options = {}) { const explicitMonth = normalizeText(options.month || options.statMonth); if (/^\d{4}-?\d{2}$/.test(explicitMonth)) { return explicitMonth.replace(/^(\d{4})(\d{2})$/, '$1-$2'); } const dateKey = normalizeDateKey(options.endDateKey || options.statDateKey || options.latestStatDateKey); if (dateKey) { return dayjs(dateKey, 'YYYYMMDD').format('YYYY-MM'); } return dayjs().subtract(1, 'day').format('YYYY-MM'); } function selectDemandProductCategory(categories = [], options = {}) { const secondaryCategoryName = normalizeText(options.secondaryCategoryName || options.twoCategory); const tertiaryCategoryName = normalizeText(options.tertiaryCategoryName || options.threeCategory); if (tertiaryCategoryName) { const exactTertiary = categories.find((category) => { return category.secondaryCategoryName === secondaryCategoryName && category.tertiaryCategoryName === tertiaryCategoryName; }); if (exactTertiary) return exactTertiary; const looseTertiary = categories.find((category) => { return category.tertiaryCategoryName === tertiaryCategoryName || category.categoryName === tertiaryCategoryName; }); if (looseTertiary) return looseTertiary; } if (secondaryCategoryName) { return categories.find((category) => category.secondaryCategoryName === secondaryCategoryName) || null; } return categories[0] || null; } function parseDemandProductStyleRows(payload = {}, category = {}, options = {}) { const rows = Array.isArray(payload.columnData) ? payload.columnData : Array.isArray(payload.hotWordList) ? payload.hotWordList : Array.isArray(payload.goodsList) ? payload.goodsList : Array.isArray(payload.list) ? payload.list : []; const limit = Math.min(Math.max(normalizeInteger(options.limit || options.pageSize, 50), 1), 100); return rows.slice(0, limit).map((row, index) => { const goodsId = normalizeText(row.goodsId || row.itemId || row.item_id || row.id); const title = normalizeText(row.goodsName || row.title || row.itemTitle || row.name); const imageUrl = normalizeText( row.goodsUrl || row.imageUrl || row.picUrl || row.mainPic || row.img || (Array.isArray(row.goodsUrlList) ? row.goodsUrlList[0] : '') ); return { rank: normalizeInteger(row.rank || row.rankNo || row.ranking, index + 1), goodsId, title, imageUrl, shop: normalizeText(row.shopName || row.shop || row.sellerName, '--'), brand: normalizeText(row.brand || row.brandName, '--'), sales: normalizeText(row.gmv || row.pay || row.salesAmount || row.saleAmount || row.gmvqj2 || '--'), saleCount: normalizeText(row.sales || row.saleCount || row.payCnt || row.payCount || '--'), unitPrice: normalizeText(row.jdj || row.unitPrice || row.price || '--'), visitor: normalizeText(row.visitor || row.uv || '--'), conversionRate: normalizeText(row.conversionRate || row.clickPay || row.payConversionRate || '--'), detailUrl: normalizeText(row.detailUrl || row.goodsDetailUrl || row.url), categoryPath: buildMarketCategoryName(category), source: 'hsds_demand_getGoodsInfoByRoot', raw: row, }; }).filter((row) => row.title || row.goodsId); } function buildDemandProductStyleFingerprint(scope = {}, row = {}) { return crypto .createHash('sha256') .update([ normalizeText(scope.platform, PLATFORM), normalizeText(scope.primaryCategoryName), normalizeText(scope.secondaryCategoryName), normalizeText(scope.tertiaryCategoryName), normalizeText(scope.rootWord), normalizeText(scope.month), normalizeText(scope.datesType), normalizeText(row.rank), normalizeText(row.goodsId), normalizeText(row.title), normalizeText(row.shop), normalizeText(row.sales), ].join('||')) .digest('hex'); } function mapDemandProductStyleCacheRow(row = {}) { return { rank: normalizeInteger(row.rankPosition || row.rank_position, 0), goodsId: normalizeText(row.goodsId || row.goods_id), title: normalizeText(row.title || row.goodsTitle || row.goods_title), imageUrl: normalizeText(row.imageUrl || row.image_url), shop: normalizeText(row.shop || row.shopName || row.shop_name, '--'), brand: normalizeText(row.brand || row.brandName || row.brand_name, '--'), sales: normalizeText(row.sales || row.salesAmount || row.sales_amount, '--'), saleCount: normalizeText(row.saleCount || row.sale_count, '--'), unitPrice: normalizeText(row.unitPrice || row.unit_price, '--'), visitor: normalizeText(row.visitor || row.visitorCount || row.visitor_count, '--'), conversionRate: normalizeText(row.conversionRate || row.conversion_rate, '--'), detailUrl: normalizeText(row.detailUrl || row.detail_url), categoryPath: normalizeText(row.marketCategoryName || row.market_category_name), source: 'planning_demand_product_styles', raw: row.rawData || row.raw_data || null, }; } function buildDemandProductStyleScope(options = {}) { return { platform: normalizeText(options.platform, PLATFORM), primaryCategoryName: normalizeText( options.primaryCategoryName || options.category || options.oneCategory || DEFAULT_PRIMARY_CATEGORY_NAME ), secondaryCategoryName: normalizeText( options.secondaryCategoryName || options.secondaryCategory || options.twoCategory ), tertiaryCategoryName: normalizeText( options.tertiaryCategoryName || options.tertiaryCategory || options.threeCategory ), rootWord: normalizeText(options.rootWord || options.keyword || options.rootWordList), month: resolveHsdsDemandMonth(options), datesType: resolveHsdsDemandDatesType(options), }; } async function getCachedPlanningHsdsDemandProductStyles(rawOptions = {}) { await ensureDemandProductStyleTableSchema(); const scope = buildDemandProductStyleScope(rawOptions); if (!scope.rootWord) { return { success: false, skipped: true, reason: 'missing_root_word', products: [], sourceMeta: { source: DEMAND_PRODUCT_STYLE_TABLE_NAME, skipped: true, reason: 'missing_root_word', }, }; } const limit = Math.min(Math.max(normalizeInteger(rawOptions.limit || rawOptions.pageSize, 50), 1), 100); const rows = await planningDatabase.query( `SELECT rank_position AS rankPosition, goods_id AS goodsId, goods_title AS goodsTitle, image_url AS imageUrl, shop_name AS shopName, brand_name AS brandName, sales_amount AS salesAmount, sale_count AS saleCount, unit_price AS unitPrice, visitor_count AS visitorCount, conversion_rate AS conversionRate, detail_url AS detailUrl, market_category_name AS marketCategoryName, raw_data AS rawData, fetched_at AS fetchedAt FROM ${escapeIdentifier(DEMAND_PRODUCT_STYLE_TABLE_NAME)} WHERE platform = ? AND primary_category_name = ? AND secondary_category_name = ? AND tertiary_category_name = ? AND root_word = ? AND month_key = ? AND dates_type = ? ORDER BY rank_position ASC, id ASC LIMIT ?`, [ scope.platform, scope.primaryCategoryName, scope.secondaryCategoryName, scope.tertiaryCategoryName, scope.rootWord, scope.month, scope.datesType, limit, ] ); const products = rows.map(mapDemandProductStyleCacheRow); return { success: true, skipped: products.length === 0, reason: products.length ? '' : 'cache_miss', products, total: products.length, sourceMeta: { source: DEMAND_PRODUCT_STYLE_TABLE_NAME, cached: true, total: products.length, rootWord: scope.rootWord, month: scope.month, datesType: scope.datesType, fetchedAt: rows[0]?.fetchedAt || null, reason: products.length ? '' : 'cache_miss', }, }; } async function replaceDemandProductStyleRows(scope = {}, category = {}, products = []) { await ensureDemandProductStyleTableSchema(); const fetchedAt = dayjs().format('YYYY-MM-DD HH:mm:ss'); const marketCategoryName = buildMarketCategoryName(category) || [ scope.primaryCategoryName, scope.secondaryCategoryName, scope.tertiaryCategoryName, ].filter(Boolean).join(' > '); await planningDatabase.withTransaction(async (connection) => { await connection.query( `DELETE FROM ${escapeIdentifier(DEMAND_PRODUCT_STYLE_TABLE_NAME)} WHERE platform = ? AND primary_category_name = ? AND secondary_category_name = ? AND tertiary_category_name = ? AND root_word = ? AND month_key = ? AND dates_type = ?`, [ scope.platform, scope.primaryCategoryName, scope.secondaryCategoryName, scope.tertiaryCategoryName, scope.rootWord, scope.month, scope.datesType, ] ); if (!products.length) { return; } const rows = products.map((product, index) => [ scope.platform, scope.primaryCategoryName, scope.secondaryCategoryName, scope.tertiaryCategoryName, marketCategoryName, normalizeText(category.categoryId), scope.rootWord, scope.month, scope.datesType, normalizeInteger(product.rank, index + 1), normalizeText(product.goodsId), normalizeText(product.title), normalizeText(product.imageUrl) || null, normalizeText(product.shop, '--'), normalizeText(product.brand, '--'), normalizeText(product.sales, '--'), normalizeText(product.saleCount, '--'), normalizeText(product.unitPrice, '--'), normalizeText(product.visitor, '--'), normalizeText(product.conversionRate, '--'), normalizeText(product.detailUrl) || null, JSON.stringify(product.raw || {}), buildDemandProductStyleFingerprint(scope, product), fetchedAt, ]); for (let index = 0; index < rows.length; index += MAX_INSERT_CHUNK_SIZE) { const chunk = rows.slice(index, index + MAX_INSERT_CHUNK_SIZE); const placeholders = chunk.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', '); await connection.query( `INSERT INTO ${escapeIdentifier(DEMAND_PRODUCT_STYLE_TABLE_NAME)} ( platform, primary_category_name, secondary_category_name, tertiary_category_name, market_category_name, cate_id, root_word, month_key, dates_type, rank_position, goods_id, goods_title, image_url, shop_name, brand_name, sales_amount, sale_count, unit_price, visitor_count, conversion_rate, detail_url, raw_data, row_fingerprint, fetched_at ) VALUES ${placeholders}`, chunk.flat() ); } }); return { table: DEMAND_PRODUCT_STYLE_TABLE_NAME, insertedRows: products.length, fetchedAt, }; } async function fetchDemandProductStylesByRoot(context, category, rootWord, options = {}) { const datesType = resolveHsdsDemandDatesType(options); const payload = await requestHsds(context, '/demand/getGoodsInfoByRoot', { query: { platform: PLATFORM, cateId: category.categoryId, month: resolveHsdsDemandMonth(options), datesType, rootWordList: rootWord, customDemandInsightCode: 'useGoodsHooks', }, ...resolveRequestControlOptions(options), }); return { rows: parseDemandProductStyleRows(payload, category, options), raw: payload, datesType, month: resolveHsdsDemandMonth(options), }; } function buildDateKeyRange(startDateKey, endDateKey) { const start = dayjs(normalizeDateKey(startDateKey), 'YYYYMMDD'); const end = dayjs(normalizeDateKey(endDateKey), 'YYYYMMDD'); if (!start.isValid() || !end.isValid() || start.isAfter(end)) { return []; } const dateKeys = []; for (let cursor = start; cursor.isSame(end) || cursor.isBefore(end); cursor = cursor.add(1, 'day')) { dateKeys.push(cursor.format('YYYYMMDD')); } return dateKeys; } async function fetchDailyHotWordRows(context, category, dateKeys = [], options = {}) { const rows = []; const failures = []; for (const dateKey of dateKeys) { const statDate = dayjs(dateKey, 'YYYYMMDD'); if (!statDate.isValid()) { continue; } try { const payload = await fetchHotWords(context, category.categoryId, { ...options, hotWordDate: statDate.format('YYYY-MM-DD'), hotWordDatesType: 'day', hotWordIsTimeChange: 'true', }); rows.push(...parseHotWordRows(payload, category, dateKey, 'keyword_daily')); } catch (error) { failures.push({ statDateKey: dateKey, message: normalizeText(error?.message || error), }); console.warn(`[HSDS] ${buildCategoryPathLabel(category)} ${dateKey} 每日热词指标抓取失败: ${error.message}`); } } return { rows, failures }; } async function fetchLayoutWords(context, category, statDateKey, options = {}) { const statDate = dayjs(statDateKey, 'YYYYMMDD'); const payload = await requestHsds(context, '/demandTrend/getWordsByDateList', { method: 'POST', data: { categoryId: category.categoryId, cateIds: category.siblingCategoryIds || category.categoryId, date: statDate.isValid() ? statDate.format('YYYY-MM-DD') : normalizeText(statDateKey), daysType: 'day', }, ...resolveRequestControlOptions(options), }); return payload || {}; } function parseHotWordRows(hotWordsPayload = {}, category = {}, statDateKey = '', sourceType = 'keyword_current') { const rows = Array.isArray(hotWordsPayload.columnData) ? hotWordsPayload.columnData : Array.isArray(hotWordsPayload.hotWordList) ? hotWordsPayload.hotWordList : []; return rows .map((item) => ({ stat_date_key: statDateKey, platform: PLATFORM, primary_category_name: category.primaryCategoryName, secondary_category_name: category.secondaryCategoryName || null, tertiary_category_name: category.tertiaryCategoryName || null, market_category_name: buildMarketCategoryName(category), search_keyword: normalizeText(item.keyWord || item.keyword), root_word: normalizeText(item.keyWord || item.keyword), search_index: normalizeMetricText(item.searchPeople), click_index: normalizeMetricText(item.clickPeople), pay_index: normalizeMetricText(item.dealPeople), click_rate: normalizeMetricText(item.clickRate), pay_conversion_rate: normalizeMetricText(item.dealRate || item.conversionRate), search_index_mom: normalizeMetricText(item.searchGrow), click_index_mom: null, pay_index_mom: null, click_rate_mom: null, pay_conversion_rate_mom: null, raw_data: { sourceType, source: '需求趋势分析', row: item, }, })) .filter((row) => row.stat_date_key && row.search_keyword); } function parseKeywordSnapshotRows(payload = {}, category = {}, options = {}) { const sourceType = normalizeText(options.sourceType || 'hot_search'); const snapshotPeriod = normalizeText(options.snapshotPeriod); const rows = Array.isArray(payload.columnData) ? payload.columnData : Array.isArray(payload.hotWordList) ? payload.hotWordList : Array.isArray(payload.soaringWordList) ? payload.soaringWordList : Array.isArray(payload.list) ? payload.list : []; const batchId = uuidv4(); let rowNumber = 1; return rows .map((item, index) => { const row = { upload_batch_id: batchId, source_file_name: KEYWORD_SNAPSHOT_SOURCE_FILE_NAME, source_sheet_name: buildCategoryPathLabel(category).slice(0, 100) || DEFAULT_PRIMARY_CATEGORY_NAME, source_sheet_type: sourceType === 'rising' ? 'category_rising_words' : 'category_hot_search_words', source_row_number: rowNumber, uploaded_by_user_id: null, uploaded_by_username: UPLOADED_BY_USERNAME, uploaded_by_name: UPLOADED_BY_NAME, uploaded_department_id: null, uploaded_department_name: null, raw_data: { source: sourceType === 'rising' ? 'HSDS 品类飙升词' : 'HSDS 品类热搜词', sourceType, row: item, }, snapshot_period: snapshotPeriod, rank_position: normalizeMetricText(item.rank || item.ranking || item.rankPosition || index + 1), market_category_name: buildMarketCategoryName(category), search_keyword: normalizeText(item.keyWord || item.keyword || item.searchKeyword || item.searchWord), search_growth_rate: sourceType === 'rising' ? normalizeMetricText(item.searchGrow || item.searchGrowthRate || item.growthRate) : undefined, search_popularity_band: normalizeMetricText(item.searchPeopleRange || item.searchPeople || item.searchPopularityBand), click_rate: normalizeMetricText(item.clickRate), pay_conversion_rate: normalizeMetricText( item.dealRateRange || item.dealRate || item.conversionRateRange || item.conversionRate ), row_fingerprint: null, }; row.row_fingerprint = buildKeywordSnapshotFingerprint(row); rowNumber += 1; return row; }) .filter((row) => row.snapshot_period && row.market_category_name && row.search_keyword); } function parseLayoutHistoryRows(layoutPayload = {}, category = {}, options = {}) { const currentDate = options.currentStatDateKey || ''; const minDateKey = computeHistoryStartDateKey(currentDate, options.historyDays || DEFAULT_HISTORY_DAYS); const groups = Array.isArray(layoutPayload.list) ? layoutPayload.list : []; const rows = []; for (const group of groups) { const rowDateKey = normalizeDateKey(group.date); if (!rowDateKey) { continue; } if (minDateKey && rowDateKey < minDateKey) { continue; } for (const word of Array.isArray(group.wordList) ? group.wordList : []) { const searchKeyword = normalizeText(word.ssc || word.keyWord || word.keyword); if (!searchKeyword) { continue; } rows.push({ stat_date_key: rowDateKey, platform: PLATFORM, primary_category_name: category.primaryCategoryName, secondary_category_name: category.secondaryCategoryName || null, tertiary_category_name: category.tertiaryCategoryName || null, market_category_name: buildMarketCategoryName(category), search_keyword: searchKeyword, root_word: searchKeyword, search_index: normalizeMetricText(word.ssrq || word.searchPeople), click_index: null, pay_index: null, click_rate: null, pay_conversion_rate: null, search_index_mom: normalizeMetricText(word.increase || word.searchGrow), click_index_mom: null, pay_index_mom: null, click_rate_mom: null, pay_conversion_rate_mom: null, raw_data: { sourceType: 'keyword_layout', source: '关键词布局分析', row: word, layoutDate: group.date, }, }); } } return rows; } function mergeDemandIndexRows(category = {}, currentRows = [], historyRows = []) { const rowMap = new Map(); const pushOrMerge = (row = {}) => { const rowDateKey = normalizeDateKey(row.stat_date_key); const searchKeyword = normalizeText(row.search_keyword); const rootWord = normalizeText(row.root_word || searchKeyword); if (!rowDateKey || !searchKeyword || !rootWord) { return; } const key = [ rowDateKey, normalizeText(category.primaryCategoryName), normalizeText(category.secondaryCategoryName), normalizeText(category.tertiaryCategoryName), searchKeyword, ].join('||'); const existing = rowMap.get(key) || {}; rowMap.set(key, { ...existing, ...row, root_word: rootWord, raw_data: { ...(existing.raw_data || {}), ...(row.raw_data || {}), }, }); }; for (const row of historyRows) { pushOrMerge(row); } for (const row of currentRows) { pushOrMerge(row); } const batchId = uuidv4(); let sourceRowNumber = 1; return Array.from(rowMap.values()) .sort((left, right) => { const dateDiff = normalizeText(left.stat_date_key).localeCompare(normalizeText(right.stat_date_key)); if (dateDiff !== 0) return dateDiff; return normalizeText(left.search_keyword).localeCompare(normalizeText(right.search_keyword), 'zh-CN'); }) .map((row) => { const finalRow = { upload_batch_id: batchId, source_file_name: SOURCE_FILE_NAME, source_sheet_name: buildCategoryPathLabel(category).slice(0, 100) || DEFAULT_PRIMARY_CATEGORY_NAME, source_sheet_type: SOURCE_SHEET_TYPE, source_row_number: sourceRowNumber, uploaded_by_user_id: null, uploaded_by_username: UPLOADED_BY_USERNAME, uploaded_by_name: UPLOADED_BY_NAME, uploaded_department_id: null, uploaded_department_name: null, raw_data: row.raw_data, row_fingerprint: null, stat_date_key: row.stat_date_key, platform: row.platform, primary_category_name: row.primary_category_name, secondary_category_name: row.secondary_category_name, tertiary_category_name: row.tertiary_category_name, market_category_name: row.market_category_name, search_keyword: row.search_keyword, root_word: row.root_word, search_index: row.search_index, click_index: row.click_index, pay_index: row.pay_index, click_rate: row.click_rate, pay_conversion_rate: row.pay_conversion_rate, search_index_mom: row.search_index_mom, click_index_mom: row.click_index_mom, pay_index_mom: row.pay_index_mom, click_rate_mom: row.click_rate_mom, pay_conversion_rate_mom: row.pay_conversion_rate_mom, }; finalRow.row_fingerprint = buildFingerprint(finalRow); sourceRowNumber += 1; return finalRow; }); } async function replaceCategoryRows(connection, category, rows = []) { if (!rows.length) { return { deletedRows: 0, insertedRows: 0 }; } const statDateKeys = rows.map((row) => normalizeDateKey(row.stat_date_key)).filter(Boolean).sort(); const minDateKey = statDateKeys[0]; const maxDateKey = statDateKeys[statDateKeys.length - 1]; const deleteParams = [ PLATFORM, category.primaryCategoryName, category.secondaryCategoryName || '', category.tertiaryCategoryName || '', minDateKey, maxDateKey, ]; const deleteResult = await connection.query( `DELETE FROM ${escapeIdentifier(TABLE_NAME)} WHERE platform = ? AND primary_category_name = ? AND COALESCE(secondary_category_name, '') = ? AND COALESCE(tertiary_category_name, '') = ? AND stat_date_key BETWEEN ? AND ?`, deleteParams ); const columnNames = Object.keys(rows[0]); for (let index = 0; index < rows.length; index += MAX_INSERT_CHUNK_SIZE) { const chunk = rows.slice(index, index + MAX_INSERT_CHUNK_SIZE); const placeholders = chunk.map(() => `(${columnNames.map(() => '?').join(', ')})`).join(', '); const values = []; for (const row of chunk) { for (const columnName of columnNames) { values.push(columnName === 'raw_data' ? JSON.stringify(row[columnName] || {}) : row[columnName]); } } await connection.query( `INSERT INTO ${escapeIdentifier(TABLE_NAME)} (${columnNames.map(escapeIdentifier).join(', ')}) VALUES ${placeholders}`, values ); } return { deletedRows: Number(deleteResult?.[0]?.affectedRows || deleteResult?.affectedRows || 0), insertedRows: rows.length, }; } async function replaceKeywordSnapshotRows(connection, tableName, category, snapshotPeriod, rows = []) { const scopeMarketCategoryName = buildMarketCategoryName(category); await connection.query( `DELETE FROM ${escapeIdentifier(tableName)} WHERE snapshot_period = ? AND market_category_name = ?`, [snapshotPeriod, scopeMarketCategoryName] ); if (!rows.length) { return { deletedRows: 0, insertedRows: 0 }; } const firstRow = rows[0]; const columnNames = Object.keys(firstRow).filter((columnName) => firstRow[columnName] !== undefined); for (let index = 0; index < rows.length; index += MAX_INSERT_CHUNK_SIZE) { const chunk = rows.slice(index, index + MAX_INSERT_CHUNK_SIZE); const placeholders = chunk.map(() => `(${columnNames.map(() => '?').join(', ')})`).join(', '); const values = []; for (const row of chunk) { for (const columnName of columnNames) { values.push(columnName === 'raw_data' ? JSON.stringify(row[columnName] || {}) : row[columnName]); } } await connection.query( `INSERT INTO ${escapeIdentifier(tableName)} (${columnNames.map(escapeIdentifier).join(', ')}) VALUES ${placeholders}`, values ); } return { deletedRows: 0, insertedRows: rows.length }; } async function syncSingleKeywordSnapshotCategory(context, category, runtimeOptions) { const keywordTime = await fetchKeywordTime(context, category.categoryId, runtimeOptions); const effectiveStatDateKey = runtimeOptions.statDateKey || normalizeDateKey(keywordTime.maxDayDate); if (!effectiveStatDateKey) { return { categoryPathLabel: buildCategoryPathLabel(category), skipped: true, reason: 'missing_stat_date', }; } const historyStartDateKey = computeHistoryStartDateKey(effectiveStatDateKey, runtimeOptions.historyDays); const snapshotPeriod = `${dayjs(historyStartDateKey, 'YYYYMMDD').format('YYYY-MM-DD')} ~ ${dayjs(effectiveStatDateKey, 'YYYYMMDD').format('YYYY-MM-DD')}`; const date = String(runtimeOptions.historyDays); const failures = []; let hotRows = null; let risingRows = null; try { const hotPayload = await fetchHotWords(context, category.categoryId, { ...runtimeOptions, date, datesType: date, hotWordIsTimeChange: 'false', }); hotRows = parseKeywordSnapshotRows(hotPayload, category, { sourceType: 'hot_search', snapshotPeriod, }); } catch (error) { failures.push({ type: 'hot_search', message: normalizeText(error?.message || error), }); } try { const risingPayload = await fetchSoaringWords(context, category.categoryId, { ...runtimeOptions, date, datesType: date, }); risingRows = parseKeywordSnapshotRows(risingPayload, category, { sourceType: 'rising', snapshotPeriod, }); } catch (error) { failures.push({ type: 'rising', message: normalizeText(error?.message || error), }); } if (runtimeOptions.dryRun) { return { categoryPathLabel: buildCategoryPathLabel(category), snapshotPeriod, hotRows: Array.isArray(hotRows) ? hotRows.length : 0, risingRows: Array.isArray(risingRows) ? risingRows.length : 0, failures, sampleHotRow: Array.isArray(hotRows) ? hotRows[0] || null : null, sampleRisingRow: Array.isArray(risingRows) ? risingRows[0] || null : null, }; } await planningDatabase.withTransaction(async (connection) => { if (Array.isArray(hotRows)) { await replaceKeywordSnapshotRows(connection, HOT_SEARCH_WORD_TABLE_NAME, category, snapshotPeriod, hotRows); } if (Array.isArray(risingRows)) { await replaceKeywordSnapshotRows(connection, RISING_WORD_TABLE_NAME, category, snapshotPeriod, risingRows); } }); return { categoryPathLabel: buildCategoryPathLabel(category), snapshotPeriod, success: failures.length === 0, hotRows: Array.isArray(hotRows) ? hotRows.length : 0, risingRows: Array.isArray(risingRows) ? risingRows.length : 0, failures, }; } async function syncSingleCategory(context, category, runtimeOptions, progress = {}) { const keywordTime = await fetchKeywordTime(context, category.categoryId, runtimeOptions); const effectiveStatDateKey = runtimeOptions.statDateKey || normalizeDateKey(keywordTime.maxDayDate); if (!effectiveStatDateKey) { return { categoryPathLabel: buildCategoryPathLabel(category), skipped: true, reason: 'missing_stat_date', }; } const historyStartDateKey = computeHistoryStartDateKey(effectiveStatDateKey, runtimeOptions.historyDays); const historyEndDateKey = effectiveStatDateKey; if (!runtimeOptions.dryRun && !runtimeOptions.forceRefresh) { const coverage = await detectExistingCoverage(category, historyStartDateKey, historyEndDateKey); const checkpoint = await getSuccessfulCheckpoint( category, effectiveStatDateKey, historyStartDateKey, historyEndDateKey ); if (checkpoint && coverage.covered) { return { categoryPathLabel: buildCategoryPathLabel(category), statDateKey: effectiveStatDateKey, skipped: true, reason: 'checkpoint_exists', currentKeywordCount: 0, historyRowCount: 0, existingRowCount: Number(checkpoint.rowCount || 0), insertedRows: 0, deletedRows: 0, }; } if (coverage.covered) { await planningDatabase.withTransaction(async (connection) => { await upsertDemandIndexCheckpoint(connection, { category, statDateKey: effectiveStatDateKey, historyStartDateKey, historyEndDateKey, historyDays: runtimeOptions.historyDays, rowCount: coverage.totalRows, status: 'success', note: 'bootstrap_from_existing_rows', fetchedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'), }); }); return { categoryPathLabel: buildCategoryPathLabel(category), statDateKey: effectiveStatDateKey, skipped: true, reason: 'existing_rows_covered', currentKeywordCount: 0, historyRowCount: 0, existingRowCount: coverage.totalRows, insertedRows: 0, deletedRows: 0, }; } } const layoutPayload = await fetchLayoutWords(context, category, effectiveStatDateKey, runtimeOptions); const historyRows = parseLayoutHistoryRows(layoutPayload, category, { historyDays: runtimeOptions.historyDays, currentStatDateKey: effectiveStatDateKey, }); const dateKeys = buildDateKeyRange(historyStartDateKey, historyEndDateKey); const dailyHotWords = await fetchDailyHotWordRows(context, category, dateKeys, runtimeOptions); const metricRows = dailyHotWords.rows; const insertRows = mergeDemandIndexRows(category, metricRows, historyRows); const metricDateCount = new Set(metricRows.map((row) => row.stat_date_key).filter(Boolean)).size; const expectedMetricDateCount = dateKeys.length; if ( runtimeOptions.strictMetricCoverage && !runtimeOptions.dryRun && (dailyHotWords.failures.length || metricDateCount < expectedMetricDateCount) ) { await planningDatabase.withTransaction(async (connection) => { await upsertDemandIndexCheckpoint(connection, { category, statDateKey: effectiveStatDateKey, historyStartDateKey, historyEndDateKey, historyDays: runtimeOptions.historyDays, rowCount: 0, status: 'failed', note: `metric_coverage_incomplete:${metricDateCount}/${expectedMetricDateCount};failures:${dailyHotWords.failures.length}`, fetchedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'), }); }); return { categoryPathLabel: buildCategoryPathLabel(category), statDateKey: effectiveStatDateKey, skipped: true, reason: 'metric_coverage_incomplete', currentKeywordCount: metricRows.length, historyRowCount: historyRows.length, metricDateCount, expectedMetricDateCount, metricFetchFailureCount: dailyHotWords.failures.length, metricFetchFailures: dailyHotWords.failures.slice(0, 5), insertedRows: 0, deletedRows: 0, }; } if (runtimeOptions.dryRun) { return { categoryPathLabel: buildCategoryPathLabel(category), statDateKey: effectiveStatDateKey, deletedRows: 0, insertedRows: insertRows.length, currentKeywordCount: metricRows.length, historyRowCount: historyRows.length, metricDateCount, expectedMetricDateCount, metricFetchFailureCount: dailyHotWords.failures.length, metricFetchFailures: dailyHotWords.failures.slice(0, 5), sampleRow: insertRows[0] || null, }; } const replaceResult = await planningDatabase.withTransaction(async (connection) => { const result = await replaceCategoryRows(connection, category, insertRows); await upsertDemandIndexCheckpoint(connection, { category, statDateKey: effectiveStatDateKey, historyStartDateKey, historyEndDateKey, historyDays: runtimeOptions.historyDays, rowCount: result.insertedRows, sourceBatchId: insertRows[0]?.upload_batch_id || null, status: dailyHotWords.failures.length ? 'failed' : 'success', note: dailyHotWords.failures.length ? `partial_metric_fetch_failed:${dailyHotWords.failures.length}` : 'synced_from_hsds_daily_metrics', fetchedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'), }); return result; }); return { categoryPathLabel: buildCategoryPathLabel(category), statDateKey: effectiveStatDateKey, currentKeywordCount: metricRows.length, historyRowCount: historyRows.length, metricDateCount, expectedMetricDateCount, metricFetchFailureCount: dailyHotWords.failures.length, metricFetchFailures: dailyHotWords.failures.slice(0, 5), sampleRow: insertRows[0] || null, ...replaceResult, }; } async function syncPlanningHsdsDemandIndices(rawOptions = {}) { const options = resolveRuntimeOptions(rawOptions); if (options.sampleOnly) { return { success: true, dryRun: true, sampleOnly: true, categoryCount: 0, totalInsertedRows: 0, totalDeletedRows: 0, results: [], note: 'HSDS 需求指数同步已切换为真实接口,legacy sampleOnly 模式不再生成样本数据。', }; } if (!options.dryRun) { await ensurePlanningCanonicalSchemas(); await ensureDemandIndexTableSchema(); await ensureDemandIndexCheckpointSchema(); } const session = await ensureAuthenticatedContext(options); try { const primaryCategory = await fetchPrimaryCategory(session.context, options); const categoryTree = await fetchCategoryTree(session.context, primaryCategory.id, options); const categories = buildLeafCategories(primaryCategory, categoryTree, options); const pickedCategories = options.categoryLimit > 0 ? categories.slice(0, options.categoryLimit) : categories; const results = []; let totalInsertedRows = 0; let totalDeletedRows = 0; let failureCount = 0; for (let index = 0; index < pickedCategories.length; index += 1) { const category = pickedCategories[index]; let result; try { result = await syncSingleCategory(session.context, category, options, { currentIndex: index + 1, totalCategories: pickedCategories.length, }); } catch (error) { failureCount += 1; result = { categoryPathLabel: buildCategoryPathLabel(category), skipped: true, reason: 'request_failed', message: normalizeText(error?.message || error), insertedRows: 0, deletedRows: 0, }; } results.push(result); totalInsertedRows += Number(result.insertedRows || 0); totalDeletedRows += Number(result.deletedRows || 0); } if (!options.dryRun) { invalidatePlanningRuntimeCache(); } return { success: failureCount === 0, dryRun: options.dryRun, sampleOnly: false, statDateKey: options.statDateKey || '', categoryCount: pickedCategories.length, failureCount, totalInsertedRows, totalDeletedRows, results, }; } finally { await session.close(); } } async function syncPlanningHsdsKeywordSnapshots(rawOptions = {}) { const options = resolveRuntimeOptions({ ...rawOptions, historyDays: rawOptions.historyDays || process.env.HSDS_KEYWORD_SNAPSHOT_HISTORY_DAYS || 7, }); if (!options.dryRun) { await ensurePlanningCanonicalSchemas(); } const session = await ensureAuthenticatedContext(options); try { const primaryCategory = await fetchPrimaryCategory(session.context, options); const categoryTree = await fetchCategoryTree(session.context, primaryCategory.id, options); const categories = buildLeafCategories(primaryCategory, categoryTree, options); const pickedCategories = options.categoryLimit > 0 ? categories.slice(0, options.categoryLimit) : categories; const results = []; let totalHotRows = 0; let totalRisingRows = 0; let failureCount = 0; for (const category of pickedCategories) { let result; try { result = await syncSingleKeywordSnapshotCategory(session.context, category, options); } catch (error) { result = { categoryPathLabel: buildCategoryPathLabel(category), skipped: true, reason: 'request_failed', message: normalizeText(error?.message || error), hotRows: 0, risingRows: 0, failures: [{ type: 'category', message: normalizeText(error?.message || error), }], }; } results.push(result); totalHotRows += Number(result.hotRows || 0); totalRisingRows += Number(result.risingRows || 0); if (result.skipped || result.success === false || (Array.isArray(result.failures) && result.failures.length)) { failureCount += 1; } } if (!options.dryRun) { invalidatePlanningRuntimeCache(); } return { success: failureCount === 0, dryRun: options.dryRun, historyDays: options.historyDays, categoryCount: pickedCategories.length, failureCount, totalHotRows, totalRisingRows, results, }; } finally { await session.close(); } } async function fetchPlanningHsdsDemandProductStyles(rawOptions = {}) { const options = resolveRuntimeOptions({ ...rawOptions, primaryCategoryName: rawOptions.primaryCategoryName || rawOptions.category || rawOptions.oneCategory || DEFAULT_PRIMARY_CATEGORY_NAME, secondaryCategoryName: rawOptions.secondaryCategoryName || rawOptions.secondaryCategory || rawOptions.twoCategory, tertiaryCategoryName: rawOptions.tertiaryCategoryName || rawOptions.tertiaryCategory || rawOptions.threeCategory, requestTimeoutMs: rawOptions.requestTimeoutMs || process.env.HSDS_DEMAND_PRODUCT_REQUEST_TIMEOUT_MS || 12000, requestDelayMs: rawOptions.requestDelayMs || 0, }); const rootWord = normalizeText(rawOptions.rootWord || rawOptions.keyword || rawOptions.rootWordList); const scope = buildDemandProductStyleScope({ ...options, ...rawOptions, rootWord, }); if (!rootWord) { return { success: false, skipped: true, reason: 'missing_root_word', products: [], }; } const session = await ensureAuthenticatedContext(options); try { const primaryCategory = await fetchPrimaryCategory(session.context, options); const categoryTree = await fetchCategoryTree(session.context, primaryCategory.id, options); const categories = buildLeafCategories(primaryCategory, categoryTree, { primaryCategoryName: options.primaryCategoryName, secondaryCategoryName: options.secondaryCategoryName, tertiaryCategoryName: options.tertiaryCategoryName, }); const category = selectDemandProductCategory(categories, options); if (!category) { return { success: false, skipped: true, reason: 'category_not_found', products: [], }; } const payload = await fetchDemandProductStylesByRoot(session.context, category, rootWord, { ...options, ...rawOptions, }); const cacheResult = rawOptions.persist === false ? null : await replaceDemandProductStyleRows(scope, category, payload.rows); return { success: true, skipped: false, rootWord, category, cateId: category.categoryId, month: payload.month, datesType: payload.datesType, products: payload.rows, total: payload.rows.length, sourceMeta: { source: 'hsds_demand_getGoodsInfoByRoot', endpoint: '/demand/getGoodsInfoByRoot', cachedTo: cacheResult?.table || '', cachedRows: cacheResult?.insertedRows || 0, categoryPath: buildMarketCategoryName(category), cateId: category.categoryId, month: payload.month, datesType: payload.datesType, }, }; } finally { await session.close(); } } async function getDemandProductStyleRootWords(options = {}) { const scope = buildDemandProductStyleScope(options); const limit = Math.min(Math.max(normalizeInteger(options.rootWordLimit || options.limit, 20), 1), 100); const endDateKey = normalizeDateKey(options.endDateKey || options.statDateKey) || await getLatestDemandIndexStatDateKey(scope); if (!endDateKey) { return []; } const rangeStart = dayjs(endDateKey, 'YYYYMMDD') .subtract(Math.max(normalizeInteger(options.historyDays || 7, 7), 1) - 1, 'day') .format('YYYYMMDD'); const rows = await planningDatabase.query( `SELECT COALESCE(NULLIF(TRIM(search_keyword), ''), NULLIF(TRIM(root_word), '')) AS rootWord, SUM(CAST(REPLACE(IFNULL(search_index, '0'), ',', '') AS DECIMAL(20, 4))) AS searchScore FROM ${escapeIdentifier(TABLE_NAME)} WHERE platform = ? AND primary_category_name = ? AND secondary_category_name = ? AND tertiary_category_name = ? AND stat_date_key BETWEEN ? AND ? GROUP BY rootWord HAVING rootWord IS NOT NULL AND rootWord <> '' ORDER BY searchScore DESC LIMIT ?`, [ scope.platform, scope.primaryCategoryName, scope.secondaryCategoryName, scope.tertiaryCategoryName, rangeStart, endDateKey, limit, ] ); return rows.map((row) => normalizeText(row.rootWord)).filter(Boolean); } async function getLatestDemandIndexStatDateKey(scope = {}) { const rows = await planningDatabase.query( `SELECT MAX(stat_date_key) AS latestStatDateKey FROM ${escapeIdentifier(TABLE_NAME)} WHERE platform = ? AND primary_category_name = ? AND secondary_category_name = ? AND tertiary_category_name = ?`, [ normalizeText(scope.platform, PLATFORM), normalizeText(scope.primaryCategoryName || DEFAULT_PRIMARY_CATEGORY_NAME), normalizeText(scope.secondaryCategoryName), normalizeText(scope.tertiaryCategoryName), ] ); return normalizeDateKey(rows[0]?.latestStatDateKey); } async function syncPlanningHsdsDemandProductStyles(rawOptions = {}) { const rootWord = normalizeText(rawOptions.rootWord || rawOptions.keyword || rawOptions.rootWordList); const rootWords = rootWord ? [rootWord] : await getDemandProductStyleRootWords(rawOptions); if (!rootWords.length) { return { success: true, skipped: true, reason: 'no_root_words', results: [], }; } const results = []; let totalProducts = 0; let failureCount = 0; for (const word of rootWords) { let result; try { result = await fetchPlanningHsdsDemandProductStyles({ ...rawOptions, rootWord: word, persist: rawOptions.persist !== false, }); } catch (error) { failureCount += 1; result = { success: false, skipped: true, reason: 'request_failed', message: normalizeText(error?.message || error), total: 0, }; } results.push({ rootWord: word, success: Boolean(result.success), skipped: Boolean(result.skipped), reason: result.reason || '', message: result.message || '', total: result.total || 0, sourceMeta: result.sourceMeta || null, }); totalProducts += Number(result.total || 0); const delayMs = Math.max(normalizeInteger(rawOptions.productRequestDelayMs || rawOptions.requestDelayMs, 5000), 0); if (delayMs) { await sleep(delayMs); } } invalidatePlanningRuntimeCache(); return { success: failureCount === 0, skipped: false, rootWordCount: rootWords.length, failureCount, totalProducts, results, }; } module.exports = { TABLE_NAME, DEFAULT_REQUEST_TIMEOUT_MS, fetchPlanningHsdsDemandProductStyles, getCachedPlanningHsdsDemandProductStyles, syncPlanningHsdsDemandProductStyles, syncPlanningHsdsDemandIndices, syncPlanningHsdsKeywordSnapshots, syncPlanningYucekjDemandIndices: syncPlanningHsdsDemandIndices, };