const dayjs = require('dayjs'); const planningDb = require('./marketReplicaDatabase'); const { acquireHsdsAccountLock } = require('./hsdsAccountLock'); const { fetchPlanningHsdsDemandProductStyles, getCachedPlanningHsdsDemandProductStyles, } = require('./planningHsdsDemandIndexSyncService'); const ROOT_WORD_TREND_TABLE = 'planning_hsds_root_word_trend_daily_source'; const HOT_SEARCH_WORD_TABLE = 'planning_category_hot_search_words'; const RISING_WORD_TABLE = 'planning_category_rising_words'; const DEMAND_INDEX_TABLE = 'keyword_category_ranking'; const DEFAULT_CATEGORY_NAME = '住宅家具'; const DEFAULT_PLATFORM = 'TX'; const DEFAULT_WINDOW_TYPE = '15d'; const DEFAULT_TREND_GRANULARITY = 'day'; const MAX_KEYWORD_SIGNAL_ROWS = 240; const DEFAULT_DEMAND_TABLE_ROW_LIMIT = 120; const MAX_DEMAND_TABLE_ROW_LIMIT = 300; const MAX_DEMAND_PRODUCT_ROWS = 50; const DEMAND_INDEX_AGGREGATE_ROW_LIMIT = 800; const DEFAULT_DEMAND_TREND_DAYS = 15; const DEMAND_TREND_DAY_OPTIONS = new Set([1, 7, 15, 30, 60]); const DEMAND_KEYWORD_SIGNAL_TIMEOUT_MS = Math.min( Math.max(Number(process.env.PLANNING_DEMAND_KEYWORD_SIGNAL_TIMEOUT_MS) || 900, 200), 3000 ); const WINDOW_TYPE_MAP = { '7天': '7d', '15天': '15d', '14天': '14d', '30天': '30d', '60天': '60d', 日: 'day', 周: 'week', 月: 'month', day: 'day', '7d': '7d', '14d': '14d', '15d': '15d', '30d': '30d', '60d': '60d', day7: '7d', day14: '14d', day15: '15d', day30: '30d', day60: '60d', week: 'week', month: 'month', '7': '7d', '14': '14d', '15': '15d', '30': '30d', '60': '60d', }; const WINDOW_LABEL_MAP = { '7d': '7天', '14d': '14天', '15d': '15天', '30d': '30天', '60d': '60天', day: '日', week: '周', month: '月', }; const TREND_GRANULARITY_SET = new Set(['day', 'week', 'month']); function queryDedicated(sql, params = []) { return planningDb.queryDedicated(sql, params); } function normalizeMarketKeywordPlatform(value) { const platform = normalizeText(value, DEFAULT_PLATFORM).toLowerCase(); if (['tx', 'tb', 'tmall', 'taobao', '淘宝', '天猫'].includes(platform)) return '淘宝'; if (['dy', 'douyin', '抖音'].includes(platform)) return '抖音'; return normalizeText(value, '淘宝'); } function normalizeText(value, fallback = '') { const text = value === undefined || value === null ? '' : String(value).trim(); return text || fallback; } function normalizeDateKey(value) { const text = normalizeText(value).replace(/[^0-9]/g, '').slice(0, 8); return text.length === 8 ? text : ''; } function normalizeWindowType(value) { return WINDOW_TYPE_MAP[normalizeText(value)] || DEFAULT_WINDOW_TYPE; } function normalizeTrendGranularity(value) { const text = normalizeText(value).toLowerCase(); return TREND_GRANULARITY_SET.has(text) ? text : DEFAULT_TREND_GRANULARITY; } function normalizeDemandTrendDays(value) { const numeric = Number.parseInt(value, 10); return DEMAND_TREND_DAY_OPTIONS.has(numeric) ? numeric : DEFAULT_DEMAND_TREND_DAYS; } function normalizeKeywordKey(value) { return normalizeText(value).replace(/\s+/g, '').toLowerCase(); } function toNumber(value, fallback = 0) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : fallback; } function toNullableNumber(value) { if (value === undefined || value === null || value === '') return null; const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; } function parseDemandTableRowLimit(value) { const numeric = Number.parseInt(value, 10); if (!Number.isFinite(numeric) || numeric <= 0) { return DEFAULT_DEMAND_TABLE_ROW_LIMIT; } return Math.min(Math.max(numeric, 24), MAX_DEMAND_TABLE_ROW_LIMIT); } function toNullableMetricNumber(value) { const text = normalizeText(value).replace(/,/g, ''); if (!text || text === '--') return null; const numeric = Number(text); return Number.isFinite(numeric) ? numeric : null; } function toNullableRateNumber(value) { const text = normalizeText(value).replace(/%/g, '%').replace(/,/g, ''); if (!text || text === '--') return null; if (text.endsWith('%')) { const numeric = Number(text.slice(0, -1)); return Number.isFinite(numeric) ? numeric / 100 : null; } const numeric = Number(text); if (!Number.isFinite(numeric)) return null; return numeric > 1 ? numeric / 100 : numeric; } function formatDateKey(value, output = 'YYYY-MM-DD') { const dateKey = normalizeDateKey(value); if (!dateKey) { return ''; } return dayjs(dateKey, 'YYYYMMDD').format(output); } function compactNumber(value) { if (value === undefined || value === null || value === '' || value === '--') { return '--'; } const numeric = Number(String(value).replace(/,/g, '')); if (!Number.isFinite(numeric)) return '--'; if (numeric === 0) return '0'; const abs = Math.abs(numeric); if (abs >= 100000000) { return `${(numeric / 100000000).toFixed(abs >= 1000000000 ? 1 : 2)}亿`; } if (abs >= 10000) { return `${(numeric / 10000).toFixed(2)}万`; } return numeric.toLocaleString('zh-CN', { maximumFractionDigits: 0 }); } function formatPercent(value, digits = 2) { const numeric = toNullableNumber(value); if (numeric === null) return '--'; return `${(numeric * 100).toFixed(digits)}%`; } function compactRatePercent(value, digits = 2) { const numeric = toNullableNumber(value); if (numeric === null) return '--'; return `${(numeric * 100).toFixed(digits)}%`; } function statusFromDelta(value) { const numeric = toNullableNumber(value); if (numeric === null || numeric === 0) return 'neutral'; return numeric > 0 ? 'up' : 'down'; } function computeDelta(currentValue, previousValue) { const current = toNumber(currentValue); const previous = toNumber(previousValue); if (!previous) return null; return current / previous - 1; } function computeComparablePeriodDelta(currentValues = [], previousValues = []) { const current = currentValues.map(toNullableMetricNumber).filter((value) => value !== null); const previous = previousValues.map(toNullableMetricNumber).filter((value) => value !== null); if (!current.length || !previous.length) { return { delta: null, currentCount: current.length, previousCount: previous.length, comparedCount: 0, strategy: 'missing_period', }; } const currentAverage = buildAverageMetric( current.reduce((sum, value) => sum + value, 0), current.length ); const previousAverage = buildAverageMetric( previous.reduce((sum, value) => sum + value, 0), previous.length ); if (current.length === 1 && previous.length >= 5 && current[0] >= previousAverage * 4) { return { delta: computeDelta(current[0], previous.reduce((sum, value) => sum + value, 0)), currentCount: current.length, previousCount: previous.length, comparedCount: previous.length, strategy: 'current_week_total', }; } if (previous.length === 1 && current.length >= 5 && previous[0] >= currentAverage * 4) { return { delta: computeDelta(current.reduce((sum, value) => sum + value, 0), previous[0]), currentCount: current.length, previousCount: previous.length, comparedCount: current.length, strategy: 'previous_week_total', }; } const comparedCount = Math.min(current.length, previous.length); const matchedCurrent = current.slice(0, comparedCount); const matchedPrevious = previous.slice(0, comparedCount); return { delta: computeDelta( buildAverageMetric(matchedCurrent.reduce((sum, value) => sum + value, 0), comparedCount), buildAverageMetric(matchedPrevious.reduce((sum, value) => sum + value, 0), comparedCount) ), currentCount: current.length, previousCount: previous.length, comparedCount, strategy: current.length === previous.length ? 'same_coverage' : 'matched_coverage', }; } function parseChineseNumberToken(value) { const text = normalizeText(value).replace(/,/g, ''); if (!text) return null; const multiplier = text.includes('亿') ? 100000000 : text.includes('万') ? 10000 : 1; const numeric = Number(text.replace(/[^\d.-]/g, '')); if (!Number.isFinite(numeric)) { return null; } return numeric * multiplier; } function parseRangeMidpoint(value) { const text = normalizeText(value); if (!text || text === '--') return null; const normalized = text .replace(/~|—|–|至/g, '~') .replace(/\s+/g, '') .replace(/以上/g, '+') .replace(/以下/g, ''); if (normalized.includes('+')) { return parseChineseNumberToken(normalized); } const parts = normalized.split('~').map(parseChineseNumberToken).filter((item) => item !== null); if (parts.length >= 2) { return (parts[0] + parts[1]) / 2; } if (parts.length === 1) { return parts[0]; } return parseChineseNumberToken(normalized); } function parsePercentRangeMidpoint(value) { const text = normalizeText(value); if (!text || text === '--') return null; const normalized = text.replace(/%/g, '%').replace(/%/g, '').trim(); const midpoint = parseRangeMidpoint(normalized); if (midpoint === null) return null; return midpoint / 100; } function extractSnapshotDateKey(value) { const text = normalizeText(value); const matches = text.match(/\d{4}-\d{2}-\d{2}/g); if (matches?.length) { return matches[matches.length - 1].replace(/-/g, ''); } const compact = text.match(/\d{8}/g); if (compact?.length) { return compact[compact.length - 1]; } const ym = text.match(/(\d{4})[-/年](\d{1,2})/u); if (ym) { return `${ym[1]}${String(Number(ym[2])).padStart(2, '0')}01`; } return ''; } function extractSnapshotMonth(value) { const dateKey = extractSnapshotDateKey(value); return dateKey ? dateKey.slice(0, 6) : ''; } function buildDemandCategoryFilter(query = {}) { const primaryCategoryName = normalizeText(query.category || query.oneCategory, DEFAULT_CATEGORY_NAME); const secondaryCategoryName = normalizeText(query.secondaryCategory || query.twoCategory || query.secondary); const tertiaryCategoryName = normalizeText(query.tertiaryCategory || query.threeCategory || query.tertiary); const categoryParts = [primaryCategoryName, secondaryCategoryName, tertiaryCategoryName].filter(Boolean); return { primaryCategoryName, secondaryCategoryName, tertiaryCategoryName, categoryParts, categoryPathLabel: categoryParts.join(' > '), }; } function buildKeywordCategoryWhere(categoryFilter = {}, keyword = '') { const clauses = []; const params = []; if (categoryFilter.primaryCategoryName) { clauses.push('market_category_name LIKE ?'); params.push(`${categoryFilter.primaryCategoryName}%`); } if (categoryFilter.secondaryCategoryName) { clauses.push('market_category_name LIKE ?'); params.push(`%${categoryFilter.secondaryCategoryName}%`); } if (categoryFilter.tertiaryCategoryName) { clauses.push('market_category_name LIKE ?'); params.push(`%${categoryFilter.tertiaryCategoryName}%`); } if (keyword) { clauses.push('search_keyword LIKE ?'); params.push(`%${keyword}%`); } return { sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', params, }; } async function getLatestStatDateKey(categoryName = DEFAULT_CATEGORY_NAME, platform = DEFAULT_PLATFORM) { const rows = await queryDedicated( `SELECT MAX(stat_date_key) AS latestDateKey FROM ${ROOT_WORD_TREND_TABLE} WHERE primary_category_name = ? AND platform = ?`, [categoryName, platform] ); return normalizeDateKey(rows[0]?.latestDateKey); } async function getDistinctRootTrendMonths(categoryName = DEFAULT_CATEGORY_NAME, platform = DEFAULT_PLATFORM) { const rows = await queryDedicated( `SELECT DISTINCT LEFT(stat_date_key, 6) AS statMonth FROM ${ROOT_WORD_TREND_TABLE} WHERE primary_category_name = ? AND platform = ? AND stat_date_key IS NOT NULL AND stat_date_key <> '' ORDER BY statMonth DESC LIMIT 36`, [categoryName, platform] ); return rows .map((row) => normalizeText(row.statMonth)) .filter(Boolean); } async function checkTableExists(tableName) { if (!/^[A-Za-z0-9_]+$/.test(String(tableName || ''))) return false; try { await queryDedicated(`SELECT 1 AS ok FROM \`${tableName}\` LIMIT 0`); return true; } catch (error) { return false; } } function buildDemandIndexWhere(categoryFilter = {}, platform = DEFAULT_PLATFORM, options = {}) { const clauses = []; const params = []; if (platform) { clauses.push('platform = ?'); params.push(normalizeMarketKeywordPlatform(platform)); } if (categoryFilter.primaryCategoryName) { clauses.push('level1_category = ?'); params.push(categoryFilter.primaryCategoryName); } if (categoryFilter.secondaryCategoryName) { clauses.push('level2_category = ?'); params.push(categoryFilter.secondaryCategoryName); } if (categoryFilter.tertiaryCategoryName) { clauses.push('level3_category = ?'); params.push(categoryFilter.tertiaryCategoryName); } const keyword = normalizeText(options.keyword); if (keyword) { clauses.push('keyword LIKE ?'); params.push(`%${keyword}%`); } const rootWord = normalizeText(options.rootWord); if (rootWord) { clauses.push('keyword = ?'); params.push(rootWord); } const keywords = Array.from(new Set((Array.isArray(options.keywords) ? options.keywords : []) .map((item) => normalizeText(item)) .filter(Boolean))) .slice(0, 60); if (keywords.length) { clauses.push(`(${keywords.map(() => 'keyword LIKE ?').join(' OR ')})`); keywords.forEach((item) => params.push(`%${item}%`)); } return { sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', params, }; } async function getLatestDemandIndexDateKey(categoryFilter = {}, platform = DEFAULT_PLATFORM) { if (!(await checkTableExists(DEMAND_INDEX_TABLE))) { return ''; } const where = buildDemandIndexWhere(categoryFilter, platform); const rows = await queryDedicated( `SELECT DATE_FORMAT(MAX(stat_date), '%Y%m%d') AS latestDateKey FROM ${DEMAND_INDEX_TABLE} ${where.sql}`, where.params ); return normalizeDateKey(rows[0]?.latestDateKey); } async function getDistinctDemandIndexMonths(categoryFilter = {}, platform = DEFAULT_PLATFORM) { if (!(await checkTableExists(DEMAND_INDEX_TABLE))) { return []; } const where = buildDemandIndexWhere(categoryFilter, platform); const rows = await queryDedicated( `SELECT DISTINCT DATE_FORMAT(stat_date, '%Y%m') AS statMonth FROM ${DEMAND_INDEX_TABLE} ${where.sql ? `${where.sql} AND stat_date IS NOT NULL` : 'WHERE stat_date IS NOT NULL'} ORDER BY statMonth DESC LIMIT 36`, where.params ); return rows.map((row) => normalizeText(row.statMonth)).filter(Boolean); } async function getDistinctKeywordSnapshotMonths(tableName, categoryFilter = {}) { const where = buildKeywordCategoryWhere(categoryFilter); const rows = await queryDedicated( `SELECT snapshot_period AS snapshotPeriod FROM ${tableName} ${where.sql} GROUP BY snapshot_period ORDER BY MAX(id) DESC LIMIT 36`, where.params ); return rows .map((row) => extractSnapshotMonth(row.snapshotPeriod)) .filter(Boolean); } async function getLatestKeywordSnapshotPeriod(tableName, categoryFilter = {}, keyword = '') { const where = buildKeywordCategoryWhere(categoryFilter, keyword); const rows = await queryDedicated( `SELECT snapshot_period AS snapshotPeriod FROM ${tableName} ${where.sql} GROUP BY snapshot_period ORDER BY MAX(id) DESC LIMIT 1`, where.params ); return normalizeText(rows[0]?.snapshotPeriod); } async function getKeywordSnapshotRows(tableName, categoryFilter = {}, snapshotPeriod = '', keyword = '') { if (!snapshotPeriod) { return []; } const where = buildKeywordCategoryWhere(categoryFilter, keyword); const searchGrowthSelect = tableName === RISING_WORD_TABLE ? 'search_growth_rate AS searchGrowthRate,' : "NULL AS searchGrowthRate,"; return queryDedicated( `SELECT snapshot_period AS snapshotPeriod, market_category_name AS marketCategoryName, search_keyword AS searchKeyword, ${searchGrowthSelect} search_popularity_band AS searchPopularityBand, click_rate AS clickRate, pay_conversion_rate AS payConversionRate FROM ${tableName} ${where.sql ? `${where.sql} AND snapshot_period = ?` : 'WHERE snapshot_period = ?'} ORDER BY id DESC LIMIT ${MAX_KEYWORD_SIGNAL_ROWS}`, [...where.params, snapshotPeriod] ); } function resolveWindowRange(windowTypeInput, latestDateKey) { const latestDate = dayjs(latestDateKey, 'YYYYMMDD'); const windowType = normalizeWindowType(windowTypeInput); if (windowType === 'day') { return { windowType, windowLabel: WINDOW_LABEL_MAP[windowType], startDateKey: latestDate.format('YYYYMMDD'), endDateKey: latestDate.format('YYYYMMDD'), periodDays: 1, }; } if (windowType === 'week') { const weekdayOffset = (latestDate.day() + 6) % 7; const start = latestDate.subtract(weekdayOffset, 'day'); return { windowType, windowLabel: WINDOW_LABEL_MAP[windowType], startDateKey: start.format('YYYYMMDD'), endDateKey: latestDate.format('YYYYMMDD'), periodDays: latestDate.diff(start, 'day') + 1, }; } if (windowType === 'month') { const start = latestDate.startOf('month'); return { windowType, windowLabel: WINDOW_LABEL_MAP[windowType], startDateKey: start.format('YYYYMMDD'), endDateKey: latestDate.format('YYYYMMDD'), periodDays: latestDate.diff(start, 'day') + 1, }; } const days = windowType === '14d' ? 14 : windowType === '15d' ? 15 : windowType === '30d' ? 30 : windowType === '60d' ? 60 : 7; return { windowType, windowLabel: WINDOW_LABEL_MAP[windowType], startDateKey: latestDate.subtract(days - 1, 'day').format('YYYYMMDD'), endDateKey: latestDate.format('YYYYMMDD'), periodDays: days, }; } function buildPreviousRange(range) { const start = dayjs(range.startDateKey, 'YYYYMMDD'); const previousEnd = start.subtract(1, 'day'); const previousStart = previousEnd.subtract(range.periodDays - 1, 'day'); return { startDateKey: previousStart.format('YYYYMMDD'), endDateKey: previousEnd.format('YYYYMMDD'), }; } async function getAggregatedRootWordRows({ categoryName, platform, startDateKey, endDateKey, keyword = '', }) { const params = [categoryName, platform, startDateKey, endDateKey]; const where = [ 'primary_category_name = ?', 'platform = ?', 'stat_date_key BETWEEN ? AND ?', ]; if (keyword) { where.push('root_word LIKE ?'); params.push(`%${keyword}%`); } return queryDedicated( `SELECT root_word AS rootWord, SUM(search_people) AS searchPeople, SUM(click_people) AS clickPeople, SUM(deal_people) AS dealPeople, MIN(stat_date_key) AS minStatDateKey, MAX(stat_date_key) AS maxStatDateKey, COUNT(*) AS coveredDays FROM ${ROOT_WORD_TREND_TABLE} WHERE ${where.join(' AND ')} GROUP BY root_word`, params ); } async function getRootWordDailyRows({ categoryName, platform, rootWord, }) { return queryDedicated( `SELECT stat_date_key AS statDateKey, search_people AS searchPeople, click_people AS clickPeople, deal_people AS dealPeople FROM ${ROOT_WORD_TREND_TABLE} WHERE primary_category_name = ? AND platform = ? AND root_word = ? ORDER BY stat_date_key ASC`, [categoryName, platform, rootWord] ); } async function getDemandIndexDailyRows({ categoryFilter = {}, platform = DEFAULT_PLATFORM, startDateKey = '', endDateKey = '', keyword = '', rootWord = '', }) { if (!(await checkTableExists(DEMAND_INDEX_TABLE))) { return []; } const where = buildDemandIndexWhere(categoryFilter, platform, { keyword, rootWord }); return queryDedicated( `SELECT DATE_FORMAT(stat_date, '%Y%m%d') AS statDateKey, level1_category AS primaryCategoryName, level2_category AS secondaryCategoryName, level3_category AS tertiaryCategoryName, CONCAT_WS(' > ', level1_category, level2_category, level3_category) AS marketCategoryName, keyword AS searchKeyword, keyword AS rootWord, search_index AS searchIndex, click_index AS clickIndex, pay_index AS payIndex, click_rate AS clickRate, pay_conversion_rate AS payConversionRate FROM ${DEMAND_INDEX_TABLE} ${where.sql ? `${where.sql} AND stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')` : "WHERE stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')"} ORDER BY stat_date ASC, id ASC`, [...where.params, startDateKey, endDateKey] ); } async function getDemandIndexTrendSeriesRows({ categoryFilter = {}, platform = DEFAULT_PLATFORM, startDateKey = '', endDateKey = '', keyword = '', keywords = [], }) { if (!(await checkTableExists(DEMAND_INDEX_TABLE))) { return []; } const where = buildDemandIndexWhere(categoryFilter, platform, { keyword, keywords }); const keywordKeyExpression = "NULLIF(keyword, '')"; return queryDedicated( `SELECT DATE_FORMAT(stat_date, '%Y%m%d') AS statDateKey, ${keywordKeyExpression} AS rootWord, ${demandSqlAverageExpression('search_index')} AS searchIndex FROM ${DEMAND_INDEX_TABLE} ${where.sql ? `${where.sql} AND stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')` : "WHERE stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')"} AND ${keywordKeyExpression} IS NOT NULL AND ${keywordKeyExpression} <> '' GROUP BY stat_date, ${keywordKeyExpression} ORDER BY stat_date ASC, rootWord ASC`, [...where.params, startDateKey, endDateKey] ); } function buildAverageMetric(sum, count) { if (!count) return null; return sum / count; } function demandSqlAverageExpression(columnName, options = {}) { const cleaned = `NULLIF(REPLACE(REPLACE(REPLACE(CAST(${columnName} AS CHAR), ',', ''), '%', ''), '%', ''), '')`; const numeric = `CAST(${cleaned} AS DECIMAL(20,6))`; if (options.rate) { return `AVG(CASE WHEN ${cleaned} IS NULL THEN NULL ELSE CASE WHEN INSTR(CAST(${columnName} AS CHAR), '%') > 0 OR INSTR(CAST(${columnName} AS CHAR), '%') > 0 OR ${numeric} > 1 THEN ${numeric} / 100 ELSE ${numeric} END END)`; } return `AVG(CASE WHEN ${cleaned} IS NULL THEN NULL ELSE ${numeric} END)`; } async function getDemandIndexAggregateRows({ categoryFilter = {}, platform = DEFAULT_PLATFORM, startDateKey = '', endDateKey = '', keyword = '', rootWord = '', keywords = [], limit = DEMAND_INDEX_AGGREGATE_ROW_LIMIT, }) { if (!(await checkTableExists(DEMAND_INDEX_TABLE))) { return []; } const safeLimit = Math.min(Math.max(Number.parseInt(limit, 10) || DEMAND_INDEX_AGGREGATE_ROW_LIMIT, 24), 2000); const where = buildDemandIndexWhere(categoryFilter, platform, { keyword, rootWord, keywords }); const keywordKeyExpression = "NULLIF(keyword, '')"; return queryDedicated( `SELECT ${keywordKeyExpression} AS rootWord, MIN(NULLIF(keyword, '')) AS searchKeyword, NULL AS sourceRootWord, MIN(level1_category) AS primaryCategoryName, MIN(level2_category) AS secondaryCategoryName, MIN(level3_category) AS tertiaryCategoryName, CONCAT_WS(' > ', MIN(level1_category), MIN(level2_category), MIN(level3_category)) AS marketCategoryName, COUNT(DISTINCT stat_date) AS coveredDays, DATE_FORMAT(MIN(stat_date), '%Y%m%d') AS minStatDateKey, DATE_FORMAT(MAX(stat_date), '%Y%m%d') AS maxStatDateKey, ${demandSqlAverageExpression('search_index')} AS searchIndex, ${demandSqlAverageExpression('click_index')} AS clickIndex, ${demandSqlAverageExpression('pay_index')} AS payIndex, ${demandSqlAverageExpression('click_rate', { rate: true })} AS clickRate, ${demandSqlAverageExpression('pay_conversion_rate', { rate: true })} AS payConversionRate FROM ${DEMAND_INDEX_TABLE} ${where.sql ? `${where.sql} AND stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')` : "WHERE stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')"} AND ${keywordKeyExpression} IS NOT NULL AND ${keywordKeyExpression} <> '' GROUP BY ${keywordKeyExpression} ORDER BY searchIndex DESC LIMIT ?`, [...where.params, startDateKey, endDateKey, safeLimit] ); } function aggregateDemandIndexRows(rows = []) { const grouped = new Map(); rows.forEach((row) => { const searchKeyword = normalizeText(row.searchKeyword); const sourceRootWord = normalizeText(row.rootWord); const displayKeyword = searchKeyword || sourceRootWord; if (!displayKeyword) return; const key = normalizeKeywordKey(displayKeyword); if (!grouped.has(key)) { grouped.set(key, { rootWord: displayKeyword, searchKeyword, sourceRootWord, marketCategoryName: normalizeText(row.marketCategoryName), primaryCategoryName: normalizeText(row.primaryCategoryName), secondaryCategoryName: normalizeText(row.secondaryCategoryName), tertiaryCategoryName: normalizeText(row.tertiaryCategoryName), minStatDateKey: normalizeDateKey(row.statDateKey), maxStatDateKey: normalizeDateKey(row.statDateKey), coveredDaysSet: new Set(), searchIndexSum: 0, searchIndexCount: 0, clickIndexSum: 0, clickIndexCount: 0, payIndexSum: 0, payIndexCount: 0, clickRateSum: 0, clickRateCount: 0, payConversionRateSum: 0, payConversionRateCount: 0, }); } const bucket = grouped.get(key); if (!bucket.searchKeyword && searchKeyword) bucket.searchKeyword = searchKeyword; if (!bucket.sourceRootWord && sourceRootWord) bucket.sourceRootWord = sourceRootWord; const statDateKey = normalizeDateKey(row.statDateKey); if (statDateKey) { bucket.coveredDaysSet.add(statDateKey); if (!bucket.minStatDateKey || statDateKey < bucket.minStatDateKey) bucket.minStatDateKey = statDateKey; if (!bucket.maxStatDateKey || statDateKey > bucket.maxStatDateKey) bucket.maxStatDateKey = statDateKey; } const searchIndex = toNullableMetricNumber(row.searchIndex); if (searchIndex !== null) { bucket.searchIndexSum += searchIndex; bucket.searchIndexCount += 1; } const clickIndex = toNullableMetricNumber(row.clickIndex); if (clickIndex !== null) { bucket.clickIndexSum += clickIndex; bucket.clickIndexCount += 1; } const payIndex = toNullableMetricNumber(row.payIndex); if (payIndex !== null) { bucket.payIndexSum += payIndex; bucket.payIndexCount += 1; } const clickRate = toNullableRateNumber(row.clickRate); if (clickRate !== null) { bucket.clickRateSum += clickRate; bucket.clickRateCount += 1; } const payConversionRate = toNullableRateNumber(row.payConversionRate); if (payConversionRate !== null) { bucket.payConversionRateSum += payConversionRate; bucket.payConversionRateCount += 1; } }); return Array.from(grouped.values()).map((item) => ({ rootWord: item.rootWord, searchKeyword: item.searchKeyword, sourceRootWord: item.sourceRootWord, marketCategoryName: item.marketCategoryName, primaryCategoryName: item.primaryCategoryName, secondaryCategoryName: item.secondaryCategoryName, tertiaryCategoryName: item.tertiaryCategoryName, coveredDays: item.coveredDaysSet.size, minStatDateKey: item.minStatDateKey, maxStatDateKey: item.maxStatDateKey, searchIndex: buildAverageMetric(item.searchIndexSum, item.searchIndexCount), clickIndex: buildAverageMetric(item.clickIndexSum, item.clickIndexCount), payIndex: buildAverageMetric(item.payIndexSum, item.payIndexCount), clickRate: buildAverageMetric(item.clickRateSum, item.clickRateCount), payConversionRate: buildAverageMetric(item.payConversionRateSum, item.payConversionRateCount), })); } function hasComparablePeriodCoverage(row = {}, previous = null, expectedDays = 1) { if (!previous) return false; const minimumCoveredDays = Math.max(1, Math.ceil(expectedDays * 0.6)); return toNumber(row.coveredDays) >= minimumCoveredDays && toNumber(previous.coveredDays) >= minimumCoveredDays; } function mapDemandIndexRowWithPrevious(row = {}, previous = null, options = {}) { const expectedDays = Math.max(1, toNumber(options.expectedDays, 1)); const comparableCoverage = hasComparablePeriodCoverage(row, previous, expectedDays); const searchIndexGrow = comparableCoverage ? computeDelta(row.searchIndex, previous?.searchIndex) : null; const clickIndexGrow = comparableCoverage ? computeDelta(row.clickIndex, previous?.clickIndex) : null; const payIndexGrow = comparableCoverage ? computeDelta(row.payIndex, previous?.payIndex) : null; const previousClickRate = previous?.clickRate ?? null; const previousPayConversionRate = previous?.payConversionRate ?? null; const clickRateDelta = row.clickRate !== null && previousClickRate !== null ? row.clickRate - previousClickRate : null; const payConversionRateDelta = row.payConversionRate !== null && previousPayConversionRate !== null ? row.payConversionRate - previousPayConversionRate : null; return { key: row.rootWord, rootWord: row.rootWord, searchKeyword: row.searchKeyword, searchIndex: row.searchIndex, clickIndex: row.clickIndex, payIndex: row.payIndex, clickRate: row.clickRate, payConversionRate: row.payConversionRate, searchIndexGrow, clickIndexGrow, payIndexGrow, clickRateDelta, payConversionRateDelta, coveredDays: row.coveredDays, previousCoveredDays: toNumber(previous?.coveredDays), expectedDays, growthQuality: comparableCoverage ? 'comparable' : 'insufficient_coverage', growthQualityDisplay: comparableCoverage ? '周期可比' : '数据不足', minStatDateKey: row.minStatDateKey, maxStatDateKey: row.maxStatDateKey, searchIndexDisplay: compactNumber(row.searchIndex), clickIndexDisplay: compactNumber(row.clickIndex), payIndexDisplay: compactNumber(row.payIndex), clickRateDisplay: formatPercent(row.clickRate), payConversionRateDisplay: formatPercent(row.payConversionRate), searchIndexGrowDisplay: formatDelta(searchIndexGrow), clickIndexGrowDisplay: formatDelta(clickIndexGrow), payIndexGrowDisplay: formatDelta(payIndexGrow), clickRateDeltaDisplay: formatDelta(clickRateDelta), payConversionRateDeltaDisplay: formatDelta(payConversionRateDelta), trendStatus: statusFromDelta(searchIndexGrow), marketCategoryName: row.marketCategoryName, searchPeople: row.searchIndex, clickPeople: row.clickIndex, dealPeople: row.payIndex, searchPeopleDisplay: compactNumber(row.searchIndex), clickPeopleDisplay: compactNumber(row.clickIndex), dealPeopleDisplay: compactNumber(row.payIndex), searchPeopleGrow: searchIndexGrow, clickPeopleGrow: clickIndexGrow, dealPeopleGrow: payIndexGrow, searchPeopleGrowDisplay: formatDelta(searchIndexGrow), }; } function normalizeDemandIndexAggregateRows(rows = []) { return rows .map((row) => { const rootWord = normalizeText(row.rootWord || row.searchKeyword); if (!rootWord) return null; return { rootWord, searchKeyword: normalizeText(row.searchKeyword || rootWord), sourceRootWord: normalizeText(row.sourceRootWord), marketCategoryName: normalizeText(row.marketCategoryName), primaryCategoryName: normalizeText(row.primaryCategoryName), secondaryCategoryName: normalizeText(row.secondaryCategoryName), tertiaryCategoryName: normalizeText(row.tertiaryCategoryName), coveredDays: toNumber(row.coveredDays), minStatDateKey: normalizeDateKey(row.minStatDateKey), maxStatDateKey: normalizeDateKey(row.maxStatDateKey), searchIndex: toNullableMetricNumber(row.searchIndex), clickIndex: toNullableMetricNumber(row.clickIndex), payIndex: toNullableMetricNumber(row.payIndex), clickRate: toNullableRateNumber(row.clickRate), payConversionRate: toNullableRateNumber(row.payConversionRate), }; }) .filter(Boolean); } function mapDemandIndexAggregateRows(currentRows = [], previousRows = [], options = {}) { const normalizedPreviousRows = normalizeDemandIndexAggregateRows(previousRows); const previousMap = new Map( normalizedPreviousRows.map((row) => [normalizeKeywordKey(row.rootWord), row]) ); return normalizeDemandIndexAggregateRows(currentRows) .map((row) => mapDemandIndexRowWithPrevious( row, previousMap.get(normalizeKeywordKey(row.rootWord)), options )) .sort((a, b) => toNumber(b.searchIndex, 0) - toNumber(a.searchIndex, 0)); } function buildDemandComparableGrowthMap(rows = [], currentRange = {}, previousRange = {}) { const grouped = new Map(); rows.forEach((row) => { const rootWord = normalizeText(row.rootWord); const statDateKey = normalizeDateKey(row.statDateKey); const searchIndex = toNullableMetricNumber(row.searchIndex); if (!rootWord || !statDateKey || searchIndex === null) return; const keywordKey = normalizeKeywordKey(rootWord); if (!grouped.has(keywordKey)) { grouped.set(keywordKey, []); } grouped.get(keywordKey).push({ statDateKey, searchIndex }); }); const result = new Map(); grouped.forEach((points, keywordKey) => { const currentValues = points .filter((point) => point.statDateKey >= currentRange.startDateKey && point.statDateKey <= currentRange.endDateKey) .sort((left, right) => left.statDateKey.localeCompare(right.statDateKey)) .map((point) => point.searchIndex); const previousValues = points .filter((point) => point.statDateKey >= previousRange.startDateKey && point.statDateKey <= previousRange.endDateKey) .sort((left, right) => left.statDateKey.localeCompare(right.statDateKey)) .map((point) => point.searchIndex); result.set(keywordKey, computeComparablePeriodDelta(currentValues, previousValues)); }); return result; } function applyDemandComparableGrowth(rows = [], growthMap = new Map()) { return rows.map((row) => { const comparison = growthMap.get(normalizeKeywordKey(row.rootWord)); if (!comparison || comparison.delta === null) { return { ...row, searchIndexGrow: null, searchIndexGrowDisplay: '--', searchPeopleGrow: null, searchPeopleGrowDisplay: '--', growthQuality: 'insufficient_coverage', growthQualityDisplay: '数据不足', }; } return { ...row, searchIndexGrow: comparison.delta, searchIndexGrowDisplay: formatDelta(comparison.delta), searchPeopleGrow: comparison.delta, searchPeopleGrowDisplay: formatDelta(comparison.delta), coveredDays: comparison.currentCount, previousCoveredDays: comparison.previousCount, comparedDays: comparison.comparedCount, comparisonStrategy: comparison.strategy, growthQuality: 'comparable', growthQualityDisplay: comparison.strategy === 'matched_coverage' ? '同天数对比' : '周期可比', trendStatus: statusFromDelta(comparison.delta), }; }); } function buildDemandTrendSeries(rows = [], latestDateKey = '', trendDays = DEFAULT_DEMAND_TREND_DAYS) { const days = normalizeDemandTrendDays(trendDays); const endDate = dayjs(latestDateKey, 'YYYYMMDD'); const dateKeys = Array.from({ length: days }, (_, index) => ( endDate.subtract(days - index - 1, 'day').format('YYYYMMDD') )); const dateKeySet = new Set(dateKeys); const globallyAvailableDateKeys = new Set( rows .map((row) => normalizeDateKey(row.statDateKey)) .filter((dateKey) => dateKeySet.has(dateKey)) ); const grouped = new Map(); rows.forEach((row) => { const rootWord = normalizeText(row.rootWord); const statDateKey = normalizeDateKey(row.statDateKey); if (!rootWord || !dateKeySet.has(statDateKey)) return; const keywordKey = normalizeKeywordKey(rootWord); if (!grouped.has(keywordKey)) { grouped.set(keywordKey, new Map()); } grouped.get(keywordKey).set(statDateKey, toNullableMetricNumber(row.searchIndex)); }); const comparisonDays = days <= 7 ? 3 : days <= 15 ? 5 : 7; const firstComparisonDateKeys = dateKeys .filter((dateKey) => globallyAvailableDateKeys.has(dateKey)) .slice(0, comparisonDays); const lastComparisonDateKeys = dateKeys.slice(-comparisonDays); const result = new Map(); grouped.forEach((valuesByDate, keywordKey) => { const series = dateKeys.map((dateKey) => ({ dateKey, value: valuesByDate.has(dateKey) ? valuesByDate.get(dateKey) : null, })); const firstValues = series .filter((point) => firstComparisonDateKeys.includes(point.dateKey)) .map((point) => point.value) .filter((value) => value !== null); const lastValues = series .filter((point) => lastComparisonDateKeys.includes(point.dateKey)) .map((point) => point.value) .filter((value) => value !== null); const coveredDays = series.filter((point) => point.value !== null).length; const comparison = computeComparablePeriodDelta(lastValues, firstValues); const comparable = comparison.delta !== null; const growth = comparison.delta; result.set(keywordKey, { trendDays: days, trendSeries: series, trendCoveredDays: coveredDays, trendGrowth: growth, trendGrowthDisplay: comparable ? formatDelta(growth) : '数据不足', trendQuality: comparable ? 'comparable' : 'insufficient_coverage', trendComparedDays: comparison.comparedCount, trendComparisonStrategy: comparison.strategy, }); }); return result; } function attachDemandTrendSeries(rows = [], trendSeriesMap = new Map()) { return rows.map((row) => ({ ...row, ...(trendSeriesMap.get(normalizeKeywordKey(row.rootWord)) || { trendDays: DEFAULT_DEMAND_TREND_DAYS, trendSeries: [], trendCoveredDays: 0, trendGrowth: null, trendGrowthDisplay: '数据不足', trendQuality: 'insufficient_coverage', }), })); } function sortDemandRows(rows = [], sortMode = 'index') { const normalizedSortMode = normalizeText(sortMode).toLowerCase(); if (normalizedSortMode !== 'trend') { return rows.slice().sort((left, right) => toNumber(right.searchIndex) - toNumber(left.searchIndex)); } return rows.slice().sort((left, right) => { const leftComparable = left.trendQuality === 'comparable'; const rightComparable = right.trendQuality === 'comparable'; if (leftComparable !== rightComparable) return rightComparable ? 1 : -1; const growthDiff = toNumber(right.trendGrowth, -Infinity) - toNumber(left.trendGrowth, -Infinity); if (growthDiff !== 0) return growthDiff; return toNumber(right.searchIndex) - toNumber(left.searchIndex); }); } function mapDemandIndexTableRows(currentRows = [], previousRows = []) { const previousMap = new Map( aggregateDemandIndexRows(previousRows).map((row) => [normalizeKeywordKey(row.rootWord), row]) ); return aggregateDemandIndexRows(currentRows) .map((row) => mapDemandIndexRowWithPrevious(row, previousMap.get(normalizeKeywordKey(row.rootWord)))) .sort((a, b) => toNumber(b.searchIndex, 0) - toNumber(a.searchIndex, 0)); } function mapRootWordTableRows(currentRows = [], previousRows = []) { const previousMap = new Map(previousRows.map((row) => [normalizeText(row.rootWord), row])); return currentRows .map((row) => { const rootWord = normalizeText(row.rootWord); const searchPeople = toNumber(row.searchPeople); const clickPeople = toNumber(row.clickPeople); const dealPeople = toNumber(row.dealPeople); const previous = previousMap.get(rootWord); const searchPeopleGrow = computeDelta(searchPeople, previous?.searchPeople); const clickPeopleGrow = computeDelta(clickPeople, previous?.clickPeople); const dealPeopleGrow = computeDelta(dealPeople, previous?.dealPeople); const clickRate = searchPeople > 0 ? clickPeople / searchPeople : null; const previousClickRate = toNumber(previous?.searchPeople) > 0 ? toNumber(previous?.clickPeople) / toNumber(previous?.searchPeople) : null; const payConversionRate = clickPeople > 0 ? dealPeople / clickPeople : null; const previousPayConversionRate = toNumber(previous?.clickPeople) > 0 ? toNumber(previous?.dealPeople) / toNumber(previous?.clickPeople) : null; const clickRateDelta = clickRate !== null && previousClickRate !== null ? clickRate - previousClickRate : null; const payConversionRateDelta = payConversionRate !== null && previousPayConversionRate !== null ? payConversionRate - previousPayConversionRate : null; return { key: rootWord, rootWord, searchPeople, clickPeople, dealPeople, clickRate, payConversionRate, searchPeopleGrow, clickPeopleGrow, dealPeopleGrow, clickRateDelta, payConversionRateDelta, coveredDays: toNumber(row.coveredDays), minStatDateKey: normalizeDateKey(row.minStatDateKey), maxStatDateKey: normalizeDateKey(row.maxStatDateKey), searchPeopleDisplay: compactNumber(searchPeople), clickPeopleDisplay: compactNumber(clickPeople), dealPeopleDisplay: compactNumber(dealPeople), clickRateDisplay: formatPercent(clickRate), payConversionRateDisplay: formatPercent(payConversionRate), searchPeopleGrowDisplay: searchPeopleGrow === null ? '--' : `${searchPeopleGrow > 0 ? '+' : ''}${(searchPeopleGrow * 100).toFixed(2)}%`, clickPeopleGrowDisplay: formatDelta(clickPeopleGrow), dealPeopleGrowDisplay: formatDelta(dealPeopleGrow), clickRateDeltaDisplay: formatDelta(clickRateDelta), payConversionRateDeltaDisplay: formatDelta(payConversionRateDelta), trendStatus: statusFromDelta(searchPeopleGrow), }; }) .sort((a, b) => b.searchPeople - a.searchPeople); } function buildDemandSummaryMetricBlueprint(metricFamily = 'people') { if (metricFamily === 'index') { return [ { label: '搜索指数', displayKey: 'searchIndexDisplay', deltaDisplayKey: 'searchIndexGrowDisplay', deltaValueKey: 'searchIndexGrow' }, { label: '点击指数', displayKey: 'clickIndexDisplay', deltaDisplayKey: 'clickIndexGrowDisplay', deltaValueKey: 'clickIndexGrow' }, { label: '支付指数', displayKey: 'payIndexDisplay', deltaDisplayKey: 'payIndexGrowDisplay', deltaValueKey: 'payIndexGrow' }, { label: '点击率', displayKey: 'clickRateDisplay', deltaDisplayKey: 'clickRateDeltaDisplay', deltaValueKey: 'clickRateDelta' }, { label: '支付转化率', displayKey: 'payConversionRateDisplay', deltaDisplayKey: 'payConversionRateDeltaDisplay', deltaValueKey: 'payConversionRateDelta' }, ]; } return [ { label: '搜索人数', displayKey: 'searchPeopleDisplay', deltaDisplayKey: 'searchPeopleGrowDisplay', deltaValueKey: 'searchPeopleGrow' }, { label: '点击人数', displayKey: 'clickPeopleDisplay', deltaDisplayKey: 'clickPeopleGrowDisplay', deltaValueKey: 'clickPeopleGrow' }, { label: '点击率', displayKey: 'clickRateDisplay', deltaDisplayKey: 'clickRateDeltaDisplay', deltaValueKey: 'clickRateDelta' }, { label: '支付人数', displayKey: 'dealPeopleDisplay', deltaDisplayKey: 'dealPeopleGrowDisplay', deltaValueKey: 'dealPeopleGrow' }, { label: '支付转化率', displayKey: 'payConversionRateDisplay', deltaDisplayKey: 'payConversionRateDeltaDisplay', deltaValueKey: 'payConversionRateDelta' }, ]; } function buildSummaryMetricsForRow(row = null, metricFamily = 'people') { if (!row) { return []; } return buildDemandSummaryMetricBlueprint(metricFamily).map((metric) => ({ label: metric.label, value: row[metric.displayKey] || '--', delta: row[metric.deltaDisplayKey] || '--', tone: statusFromDelta(row[metric.deltaValueKey]), rawValue: row[metric.rawValueKey || metric.displayKey] ?? null, })); } function buildKeywordSummaryMetrics(summary = {}) { return [ { label: '热搜词数', value: compactNumber(summary.hotKeywordCount || 0), delta: summary.latestSnapshotMonth ? formatCompactMonth(summary.latestSnapshotMonth) : '--', tone: 'neutral', }, { label: '飙升词数', value: compactNumber(summary.risingKeywordCount || 0), delta: summary.latestSnapshotMonth ? formatCompactMonth(summary.latestSnapshotMonth) : '--', tone: 'neutral', }, { label: '双榜重合', value: compactNumber(summary.overlapKeywordCount || 0), delta: summary.categoryPathLabel || '--', tone: 'up', }, { label: '当前快照', value: summary.latestSnapshotMonth ? formatCompactMonth(summary.latestSnapshotMonth) : '--', delta: summary.keywordCount ? `${summary.keywordCount}个关键词` : '--', tone: 'neutral', }, ]; } function formatDelta(value) { const numeric = toNullableNumber(value); if (numeric === null) return '--'; return `${numeric > 0 ? '+' : ''}${(numeric * 100).toFixed(2)}%`; } function formatCompactMonth(value) { const text = normalizeText(value).replace(/[^0-9]/g, '').slice(0, 6); if (text.length !== 6) return '--'; return `${text.slice(0, 4)}-${text.slice(4, 6)}`; } function formatRangeLabel(startDateKey, endDateKey) { const start = dayjs(startDateKey, 'YYYYMMDD'); const end = dayjs(endDateKey, 'YYYYMMDD'); if (!start.isValid() || !end.isValid()) return ''; return `${start.format('YYYY年M月D日')} - ${end.format('YYYY年M月D日')}`; } function buildTrendGroups(rows = [], granularity = DEFAULT_TREND_GRANULARITY) { const groups = new Map(); rows.forEach((row) => { const date = dayjs(row.statDateKey, 'YYYYMMDD'); if (!date.isValid()) { return; } let key = row.statDateKey; let label = date.format('YYYY/MM/DD'); if (granularity === 'week') { const weekdayOffset = (date.day() + 6) % 7; const weekStart = date.subtract(weekdayOffset, 'day'); const weekEnd = weekStart.add(6, 'day'); key = weekStart.format('YYYYMMDD'); label = `${weekStart.format('YYYY/MM/DD')} - ${weekEnd.format('MM/DD')}`; } else if (granularity === 'month') { key = date.format('YYYYMM'); label = date.format('YYYY/MM'); } if (!groups.has(key)) { groups.set(key, { key, label, statDateKey: row.statDateKey, searchPeople: 0, clickPeople: 0, dealPeople: 0, }); } const bucket = groups.get(key); bucket.searchPeople += toNumber(row.searchPeople); bucket.clickPeople += toNumber(row.clickPeople); bucket.dealPeople += toNumber(row.dealPeople); bucket.statDateKey = row.statDateKey; }); return Array.from(groups.values()) .sort((a, b) => a.key.localeCompare(b.key)) .map((item) => { const clickRate = item.searchPeople > 0 ? item.clickPeople / item.searchPeople : null; const payConversionRate = item.clickPeople > 0 ? item.dealPeople / item.clickPeople : null; return { key: item.key, date: item.label, statDateKey: item.statDateKey, searchPeople: item.searchPeople, clickPeople: item.clickPeople, dealPeople: item.dealPeople, clickRate, payConversionRate, searchPeopleDisplay: compactNumber(item.searchPeople), clickPeopleDisplay: compactNumber(item.clickPeople), dealPeopleDisplay: compactNumber(item.dealPeople), clickRateDisplay: formatPercent(clickRate), payConversionRateDisplay: formatPercent(payConversionRate), }; }); } function buildDemandIndexTrendGroups(rows = [], granularity = DEFAULT_TREND_GRANULARITY) { const groups = new Map(); rows.forEach((row) => { const date = dayjs(row.statDateKey, 'YYYYMMDD'); if (!date.isValid()) return; let key = row.statDateKey; let label = date.format('YYYY/MM/DD'); if (granularity === 'week') { const weekdayOffset = (date.day() + 6) % 7; const weekStart = date.subtract(weekdayOffset, 'day'); const weekEnd = weekStart.add(6, 'day'); key = weekStart.format('YYYYMMDD'); label = `${weekStart.format('YYYY/MM/DD')} - ${weekEnd.format('MM/DD')}`; } else if (granularity === 'month') { key = date.format('YYYYMM'); label = date.format('YYYY/MM'); } if (!groups.has(key)) { groups.set(key, { key, date: label, statDateKey: row.statDateKey, searchIndexSum: 0, searchIndexCount: 0, clickIndexSum: 0, clickIndexCount: 0, payIndexSum: 0, payIndexCount: 0, clickRateSum: 0, clickRateCount: 0, payConversionRateSum: 0, payConversionRateCount: 0, }); } const bucket = groups.get(key); const searchIndex = toNullableMetricNumber(row.searchIndex); if (searchIndex !== null) { bucket.searchIndexSum += searchIndex; bucket.searchIndexCount += 1; } const clickIndex = toNullableMetricNumber(row.clickIndex); if (clickIndex !== null) { bucket.clickIndexSum += clickIndex; bucket.clickIndexCount += 1; } const payIndex = toNullableMetricNumber(row.payIndex); if (payIndex !== null) { bucket.payIndexSum += payIndex; bucket.payIndexCount += 1; } const clickRate = toNullableRateNumber(row.clickRate); if (clickRate !== null) { bucket.clickRateSum += clickRate; bucket.clickRateCount += 1; } const payConversionRate = toNullableRateNumber(row.payConversionRate); if (payConversionRate !== null) { bucket.payConversionRateSum += payConversionRate; bucket.payConversionRateCount += 1; } bucket.statDateKey = row.statDateKey; }); return Array.from(groups.values()) .sort((a, b) => a.key.localeCompare(b.key)) .map((item) => { const searchIndex = buildAverageMetric(item.searchIndexSum, item.searchIndexCount); const clickIndex = buildAverageMetric(item.clickIndexSum, item.clickIndexCount); const payIndex = buildAverageMetric(item.payIndexSum, item.payIndexCount); const clickRate = buildAverageMetric(item.clickRateSum, item.clickRateCount); const payConversionRate = buildAverageMetric(item.payConversionRateSum, item.payConversionRateCount); return { key: item.key, date: item.date, statDateKey: item.statDateKey, searchIndex, clickIndex, payIndex, clickRate, payConversionRate, searchIndexDisplay: compactNumber(searchIndex), clickIndexDisplay: compactNumber(clickIndex), payIndexDisplay: compactNumber(payIndex), clickRateDisplay: formatPercent(clickRate), payConversionRateDisplay: formatPercent(payConversionRate), }; }); } function buildKeywordSignalRows({ rootRows = [], hotRows = [], risingRows = [], categoryFilter = {}, }) { const rootRowMap = new Map(rootRows.map((row) => [normalizeKeywordKey(row.rootWord), row])); const combined = new Map(); const appendRows = (rows, type) => { rows.forEach((row) => { const rootWord = normalizeText(row.searchKeyword); const keywordKey = normalizeKeywordKey(rootWord); if (!keywordKey) return; const existing = combined.get(keywordKey) || { key: rootWord, rootWord, keywordTypes: new Set(), marketCategoryName: normalizeText(row.marketCategoryName), snapshotMonths: new Set(), }; existing.keywordTypes.add(type); existing.marketCategoryName = existing.marketCategoryName || normalizeText(row.marketCategoryName); const snapshotMonth = extractSnapshotMonth(row.snapshotPeriod); if (snapshotMonth) { existing.snapshotMonths.add(snapshotMonth); } if (type === 'hot') { existing.searchPopularityBand = normalizeText(row.searchPopularityBand); existing.searchPopularityScore = parseRangeMidpoint(row.searchPopularityBand); } if (type === 'rising') { existing.searchGrowthRate = normalizeText(row.searchGrowthRate); existing.searchGrowthRateValue = parsePercentRangeMidpoint(row.searchGrowthRate); } if (!existing.clickRateDisplay || existing.clickRateDisplay === '--') { existing.clickRateDisplay = normalizeText(row.clickRate, '--'); } if (!existing.payConversionRateDisplay || existing.payConversionRateDisplay === '--') { existing.payConversionRateDisplay = normalizeText(row.payConversionRate, '--'); } existing.clickRateValue = existing.clickRateValue ?? parsePercentRangeMidpoint(row.clickRate); existing.payConversionRateValue = existing.payConversionRateValue ?? parsePercentRangeMidpoint(row.payConversionRate); combined.set(keywordKey, existing); }); }; appendRows(hotRows, 'hot'); appendRows(risingRows, 'rising'); const rows = Array.from(combined.values()).map((row) => { const trendRow = rootRowMap.get(normalizeKeywordKey(row.rootWord)) || null; const keywordTypeDisplay = row.keywordTypes.has('hot') && row.keywordTypes.has('rising') ? '热搜+飙升' : row.keywordTypes.has('rising') ? '飙升词' : '热搜词'; return { key: row.rootWord, rootWord: row.rootWord, keywordTypeDisplay, marketCategoryName: row.marketCategoryName || categoryFilter.categoryPathLabel || DEFAULT_CATEGORY_NAME, searchPopularityBandDisplay: row.searchPopularityBand || '--', searchPopularityScore: row.searchPopularityScore ?? null, clickRateDisplay: row.clickRateDisplay || trendRow?.clickRateDisplay || '--', clickRate: row.clickRateValue ?? trendRow?.clickRate ?? null, payConversionRateDisplay: row.payConversionRateDisplay || trendRow?.payConversionRateDisplay || '--', payConversionRate: row.payConversionRateValue ?? trendRow?.payConversionRate ?? null, searchGrowthRateDisplay: row.searchGrowthRate || trendRow?.searchPeopleGrowDisplay || '--', searchGrowthRate: row.searchGrowthRateValue ?? trendRow?.searchPeopleGrow ?? null, searchIndex: trendRow?.searchIndex ?? null, searchIndexDisplay: trendRow?.searchIndexDisplay || row.searchPopularityBand || '--', searchIndexGrow: trendRow?.searchIndexGrow ?? null, searchIndexGrowDisplay: trendRow?.searchIndexGrowDisplay || '--', clickIndex: trendRow?.clickIndex ?? null, clickIndexDisplay: trendRow?.clickIndexDisplay || '--', clickIndexGrow: trendRow?.clickIndexGrow ?? null, clickIndexGrowDisplay: trendRow?.clickIndexGrowDisplay || '--', payIndex: trendRow?.payIndex ?? null, payIndexDisplay: trendRow?.payIndexDisplay || '--', payIndexGrow: trendRow?.payIndexGrow ?? null, payIndexGrowDisplay: trendRow?.payIndexGrowDisplay || '--', searchPeople: trendRow?.searchPeople ?? (row.searchPopularityScore ?? 0), searchPeopleDisplay: trendRow?.searchPeopleDisplay || row.searchPopularityBand || '--', clickPeople: trendRow?.clickPeople ?? null, clickPeopleDisplay: trendRow?.clickPeopleDisplay || '--', dealPeople: trendRow?.dealPeople ?? null, dealPeopleDisplay: trendRow?.dealPeopleDisplay || '--', clickPeopleGrow: trendRow?.clickPeopleGrow ?? null, clickPeopleGrowDisplay: trendRow?.clickPeopleGrowDisplay || '--', dealPeopleGrow: trendRow?.dealPeopleGrow ?? null, dealPeopleGrowDisplay: trendRow?.dealPeopleGrowDisplay || '--', clickRateDelta: trendRow?.clickRateDelta ?? null, clickRateDeltaDisplay: trendRow?.clickRateDeltaDisplay || '--', payConversionRateDelta: trendRow?.payConversionRateDelta ?? null, payConversionRateDeltaDisplay: trendRow?.payConversionRateDeltaDisplay || '--', trendStatus: statusFromDelta(row.searchGrowthRateValue ?? trendRow?.searchPeopleGrow), latestSnapshotMonth: Array.from(row.snapshotMonths).sort().pop() || '', }; }); rows.sort((left, right) => { const rightPrimaryScore = right.searchIndex ?? right.searchPopularityScore; const leftPrimaryScore = left.searchIndex ?? left.searchPopularityScore; const scoreDiff = toNumber(rightPrimaryScore, -1) - toNumber(leftPrimaryScore, -1); if (scoreDiff !== 0) return scoreDiff; const growthDiff = toNumber(right.searchGrowthRate, -999999) - toNumber(left.searchGrowthRate, -999999); if (growthDiff !== 0) return growthDiff; return normalizeText(left.rootWord).localeCompare(normalizeText(right.rootWord), 'zh-CN'); }); return rows; } function mergeDemandTrendRowsWithKeywordSignals(rows = [], keywordRows = []) { const keywordMap = new Map(keywordRows.map((row) => [normalizeKeywordKey(row.rootWord), row])); return rows.map((row) => { const keywordRow = keywordMap.get(normalizeKeywordKey(row.rootWord)); if (!keywordRow) { return { ...row, keywordTypeDisplay: '--', searchPopularityBandDisplay: '--', snapshotClickRateDisplay: '--', snapshotPayConversionRateDisplay: '--', }; } return { ...row, keywordTypeDisplay: keywordRow.keywordTypeDisplay, searchPopularityBandDisplay: keywordRow.searchPopularityBandDisplay, snapshotClickRateDisplay: keywordRow.clickRateDisplay, snapshotPayConversionRateDisplay: keywordRow.payConversionRateDisplay, searchGrowthRateDisplay: row.searchPeopleGrowDisplay, }; }); } function slimDemandTableRow(row = {}) { return { key: row.key || row.rootWord || row.searchKeyword, rootWord: row.rootWord || row.searchKeyword || '', searchKeyword: row.searchKeyword || row.rootWord || '', searchIndex: row.searchIndex, clickIndex: row.clickIndex, payIndex: row.payIndex, clickRate: row.clickRate, payConversionRate: row.payConversionRate, searchIndexGrow: row.searchIndexGrow, clickIndexGrow: row.clickIndexGrow, payIndexGrow: row.payIndexGrow, clickRateDelta: row.clickRateDelta, payConversionRateDelta: row.payConversionRateDelta, searchIndexDisplay: row.searchIndexDisplay || '--', clickIndexDisplay: row.clickIndexDisplay || '--', payIndexDisplay: row.payIndexDisplay || '--', clickRateDisplay: row.clickRateDisplay || '--', payConversionRateDisplay: row.payConversionRateDisplay || '--', searchIndexGrowDisplay: row.searchIndexGrowDisplay || '--', clickIndexGrowDisplay: row.clickIndexGrowDisplay || '--', payIndexGrowDisplay: row.payIndexGrowDisplay || '--', clickRateDeltaDisplay: row.clickRateDeltaDisplay || '--', payConversionRateDeltaDisplay: row.payConversionRateDeltaDisplay || '--', trendStatus: row.trendStatus || 'neutral', keywordTypeDisplay: row.keywordTypeDisplay || '--', searchPopularityBandDisplay: row.searchPopularityBandDisplay || '--', snapshotClickRateDisplay: row.snapshotClickRateDisplay || '--', snapshotPayConversionRateDisplay: row.snapshotPayConversionRateDisplay || '--', searchGrowthRateDisplay: row.searchGrowthRateDisplay || row.searchPeopleGrowDisplay || '--', searchPeople: row.searchPeople ?? row.searchIndex, clickPeople: row.clickPeople ?? row.clickIndex, dealPeople: row.dealPeople ?? row.payIndex, searchPeopleDisplay: row.searchPeopleDisplay || row.searchIndexDisplay || '--', clickPeopleDisplay: row.clickPeopleDisplay || row.clickIndexDisplay || '--', dealPeopleDisplay: row.dealPeopleDisplay || row.payIndexDisplay || '--', searchPeopleGrow: row.searchPeopleGrow ?? row.searchIndexGrow, clickPeopleGrow: row.clickPeopleGrow ?? row.clickIndexGrow, dealPeopleGrow: row.dealPeopleGrow ?? row.payIndexGrow, searchPeopleGrowDisplay: row.searchPeopleGrowDisplay || row.searchIndexGrowDisplay || '--', coveredDays: row.coveredDays, previousCoveredDays: row.previousCoveredDays, comparedDays: row.comparedDays, comparisonStrategy: row.comparisonStrategy || 'missing_period', expectedDays: row.expectedDays, growthQuality: row.growthQuality || 'insufficient_coverage', growthQualityDisplay: row.growthQualityDisplay || '数据不足', trendDays: row.trendDays || DEFAULT_DEMAND_TREND_DAYS, trendSeries: Array.isArray(row.trendSeries) ? row.trendSeries : [], trendCoveredDays: row.trendCoveredDays || 0, trendGrowth: row.trendGrowth ?? null, trendGrowthDisplay: row.trendGrowthDisplay || '数据不足', trendQuality: row.trendQuality || 'insufficient_coverage', trendComparedDays: row.trendComparedDays || 0, trendComparisonStrategy: row.trendComparisonStrategy || 'missing_period', }; } function buildDemandTablePayloadRows(tableRows = [], selectedRootWord = '', rowLimit = DEFAULT_DEMAND_TABLE_ROW_LIMIT) { const limit = parseDemandTableRowLimit(rowLimit); const payloadRows = tableRows.slice(0, limit); const selectedKey = normalizeKeywordKey(selectedRootWord); if (selectedKey && !payloadRows.some((row) => normalizeKeywordKey(row.rootWord) === selectedKey)) { const selectedRow = tableRows.find((row) => normalizeKeywordKey(row.rootWord) === selectedKey); if (selectedRow) { payloadRows.unshift(selectedRow); payloadRows.splice(limit); } } return payloadRows.map(slimDemandTableRow); } function buildDemandBoardRowsPayload({ historyRows = [], latestTrendRows = [], hotRows = [], risingRows = [], categoryFilter = {}, selectedRootWord = '', rowLimit = DEFAULT_DEMAND_TABLE_ROW_LIMIT, }) { const hotBoardRows = buildKeywordSignalRows({ rootRows: latestTrendRows, hotRows, risingRows: [], categoryFilter, }); const risingBoardRows = buildKeywordSignalRows({ rootRows: latestTrendRows, hotRows: [], risingRows, categoryFilter, }).sort((left, right) => { const growthDiff = toNumber(right.searchGrowthRate, -999999) - toNumber(left.searchGrowthRate, -999999); if (growthDiff !== 0) return growthDiff; return toNumber(right.searchIndex, -1) - toNumber(left.searchIndex, -1); }); return { history: buildDemandTablePayloadRows(historyRows, selectedRootWord, rowLimit), hot: buildDemandTablePayloadRows(hotBoardRows, selectedRootWord, rowLimit), rising: buildDemandTablePayloadRows(risingBoardRows, selectedRootWord, rowLimit), }; } function emptyDemandKeywordSignals(categoryFilter = {}, reason = '') { return { categoryFilter, hotSnapshotPeriod: '', risingSnapshotPeriod: '', latestSnapshotMonth: '', availableMonths: [], hotRows: [], risingRows: [], skipped: Boolean(reason), skipReason: reason, }; } function withDemandKeywordSignalTimeout(promise, categoryFilter = {}) { let timer = null; const timeoutPromise = new Promise((resolve) => { timer = setTimeout(() => resolve(emptyDemandKeywordSignals(categoryFilter, 'timeout')), DEMAND_KEYWORD_SIGNAL_TIMEOUT_MS); if (typeof timer.unref === 'function') { timer.unref(); } }); return Promise.race([promise, timeoutPromise]).finally(() => { if (timer) clearTimeout(timer); }); } async function getDemandKeywordSignals(query = {}) { const categoryFilter = buildDemandCategoryFilter(query); const keyword = normalizeText(query.keyword || query.rootWordKeyword); const [hasHotTable, hasRisingTable] = await Promise.all([ checkTableExists(HOT_SEARCH_WORD_TABLE), checkTableExists(RISING_WORD_TABLE), ]); if (!hasHotTable && !hasRisingTable) { return emptyDemandKeywordSignals(categoryFilter, 'optional_snapshot_tables_missing'); } const [hotSnapshotPeriod, risingSnapshotPeriod, hotMonths, risingMonths] = await Promise.all([ hasHotTable ? getLatestKeywordSnapshotPeriod(HOT_SEARCH_WORD_TABLE, categoryFilter, keyword) : '', hasRisingTable ? getLatestKeywordSnapshotPeriod(RISING_WORD_TABLE, categoryFilter, keyword) : '', hasHotTable ? getDistinctKeywordSnapshotMonths(HOT_SEARCH_WORD_TABLE, categoryFilter) : [], hasRisingTable ? getDistinctKeywordSnapshotMonths(RISING_WORD_TABLE, categoryFilter) : [], ]); const [hotRows, risingRows] = await Promise.all([ hasHotTable ? getKeywordSnapshotRows(HOT_SEARCH_WORD_TABLE, categoryFilter, hotSnapshotPeriod, keyword) : [], hasRisingTable ? getKeywordSnapshotRows(RISING_WORD_TABLE, categoryFilter, risingSnapshotPeriod, keyword) : [], ]); return { categoryFilter, hotSnapshotPeriod, risingSnapshotPeriod, latestSnapshotMonth: [extractSnapshotMonth(hotSnapshotPeriod), extractSnapshotMonth(risingSnapshotPeriod)] .filter(Boolean) .sort() .pop() || '', availableMonths: Array.from(new Set([...hotMonths, ...risingMonths])).sort().reverse(), hotRows, risingRows, }; } async function getPlanningDemandWordAnalysis(query = {}) { const mode = normalizeText(query.mode, 'product').toLowerCase(); const requestedCategoryFilter = buildDemandCategoryFilter(query); let categoryFilter = requestedCategoryFilter; let primaryCategoryName = categoryFilter.primaryCategoryName; const platform = normalizeText(query.platform, DEFAULT_PLATFORM); const keyword = normalizeText(query.keyword || query.rootWordKeyword); const allowDemandIndexTable = ['product', 'trend', 'keyword'].includes(mode); let latestIndexStatDateKey = allowDemandIndexTable ? await getLatestDemandIndexDateKey(categoryFilter, platform) : ''; let fallbackCategoryMeta = null; if (!latestIndexStatDateKey && categoryFilter.tertiaryCategoryName) { const secondaryOnlyCategoryFilter = buildDemandCategoryFilter({ category: categoryFilter.primaryCategoryName, oneCategory: categoryFilter.primaryCategoryName, twoCategory: categoryFilter.secondaryCategoryName, }); const secondaryLatestDateKey = allowDemandIndexTable ? await getLatestDemandIndexDateKey(secondaryOnlyCategoryFilter, platform) : ''; if (secondaryLatestDateKey) { fallbackCategoryMeta = { reason: 'missing_tertiary_demand_index', requestedCategoryPath: requestedCategoryFilter.categoryPathLabel, effectiveCategoryPath: secondaryOnlyCategoryFilter.categoryPathLabel, }; categoryFilter = secondaryOnlyCategoryFilter; primaryCategoryName = categoryFilter.primaryCategoryName; latestIndexStatDateKey = secondaryLatestDateKey; } } const latestStatDateKey = latestIndexStatDateKey; const demandIndexMonthsPromise = getDistinctDemandIndexMonths(categoryFilter, platform).catch(() => []); // The dedicated daily keyword table is the single source of truth for market // selection. Legacy hot/rising snapshots live in the planning database and // must not be mixed into current market evidence. const keywordSignalsPromise = Promise.resolve( emptyDemandKeywordSignals(categoryFilter, 'dedicated_market_keyword_table') ); if (!latestStatDateKey) { const [demandIndexMonths, keywordSignals] = await Promise.all([ demandIndexMonthsPromise, keywordSignalsPromise, ]); const availableMonths = Array.from(new Set([ ...demandIndexMonths, ...(keywordSignals.availableMonths || []), ])).sort().reverse(); return { demand: { header: { platform, primaryCategoryName, windowType: normalizeWindowType(query.dateShortcut || query.windowType), windowLabel: WINDOW_LABEL_MAP[normalizeWindowType(query.dateShortcut || query.windowType)], latestStatDateKey: '', statRangeLabel: '', }, summaryMetrics: [], rootWordGroups: [], tableRows: [], selectedRootWord: '', sourceMeta: { source: DEMAND_INDEX_TABLE, totalWords: 0, latestKeywordSnapshotMonth: keywordSignals.latestSnapshotMonth || '', missingDedicatedDemandIndex: true, note: `当前类目未命中 ${DEMAND_INDEX_TABLE} 专用市场库关键词数据,需求分析不再降级使用企划库旧快照。`, }, }, trend: [], statMonth: availableMonths[0] || '', effectiveMonthRange: [], availableMonths, }; } const currentRange = resolveWindowRange(query.dateShortcut || query.windowType, latestStatDateKey); const previousRange = buildPreviousRange(currentRange); const trendDays = normalizeDemandTrendDays(query.trendDays); const trendStartDateKey = dayjs(latestStatDateKey, 'YYYYMMDD') .subtract(trendDays - 1, 'day') .format('YYYYMMDD'); const trendQueryStartDateKey = trendStartDateKey < previousRange.startDateKey ? trendStartDateKey : previousRange.startDateKey; const [currentRows, previousRows, trendSeriesRows, demandIndexMonths, keywordSignals] = await Promise.all([ getDemandIndexAggregateRows({ categoryFilter, platform, startDateKey: currentRange.startDateKey, endDateKey: currentRange.endDateKey, keyword, limit: DEMAND_INDEX_AGGREGATE_ROW_LIMIT, }), getDemandIndexAggregateRows({ categoryFilter, platform, startDateKey: previousRange.startDateKey, endDateKey: previousRange.endDateKey, keyword, limit: DEMAND_INDEX_AGGREGATE_ROW_LIMIT, }), getDemandIndexTrendSeriesRows({ categoryFilter, platform, startDateKey: trendQueryStartDateKey, endDateKey: latestStatDateKey, }), demandIndexMonthsPromise, keywordSignalsPromise, ]); const demandTrendRows = attachDemandTrendSeries( applyDemandComparableGrowth( mapDemandIndexAggregateRows(currentRows, previousRows, { expectedDays: currentRange.periodDays }), buildDemandComparableGrowthMap(trendSeriesRows, currentRange, previousRange) ), buildDemandTrendSeries(trendSeriesRows, latestStatDateKey, trendDays) ); const latestDemandTrendRows = demandTrendRows; const tableBaseRows = demandTrendRows.length ? demandTrendRows : latestDemandTrendRows; const keywordRows = buildKeywordSignalRows({ rootRows: latestDemandTrendRows, hotRows: keywordSignals.hotRows, risingRows: keywordSignals.risingRows, categoryFilter, }); const mergedTrendRows = mergeDemandTrendRowsWithKeywordSignals(demandTrendRows, keywordRows); const mergedSnapshotRows = mergeDemandTrendRowsWithKeywordSignals(tableBaseRows, keywordRows); const unsortedTableRows = mode === 'trend' ? mergedTrendRows : mergedSnapshotRows; const tableRows = sortDemandRows(unsortedTableRows, query.sortMode); const topRows = tableRows.slice(0, 24); const selectedRootWord = normalizeText(query.rootWord, topRows[0]?.rootWord || ''); const selectedRow = tableRows.find((row) => row.rootWord === selectedRootWord) || tableRows[0] || null; let products = []; let productStylesSourceMeta = { source: 'hsds_demand_getGoodsInfoByRoot', skipped: true, reason: mode === 'product' ? 'not_requested' : 'non_product_mode', }; if (mode === 'product' && selectedRow?.rootWord) { try { const cachedPayload = await getCachedPlanningHsdsDemandProductStyles({ category: categoryFilter.primaryCategoryName, oneCategory: categoryFilter.primaryCategoryName, secondaryCategory: categoryFilter.secondaryCategoryName, twoCategory: categoryFilter.secondaryCategoryName, tertiaryCategory: categoryFilter.tertiaryCategoryName, threeCategory: categoryFilter.tertiaryCategoryName, rootWord: selectedRow.rootWord, dateShortcut: query.dateShortcut || query.windowType, statDateKey: latestStatDateKey, endDateKey: currentRange.endDateKey, limit: MAX_DEMAND_PRODUCT_ROWS, }); products = Array.isArray(cachedPayload.products) ? cachedPayload.products : []; productStylesSourceMeta = { ...(cachedPayload.sourceMeta || {}), skipped: products.length === 0, reason: products.length ? '' : (cachedPayload.reason || 'cache_miss'), total: products.length, rootWord: selectedRow.rootWord, }; } catch (error) { productStylesSourceMeta = { source: 'planning_demand_product_styles', skipped: true, reason: 'cache_read_failed', message: normalizeText(error?.message || error), rootWord: selectedRow.rootWord, }; } if (products.length === 0) { const lockState = await acquireHsdsAccountLock('planning_demand_product_styles'); if (!lockState.acquired) { productStylesSourceMeta = { ...productStylesSourceMeta, source: 'hsds_demand_getGoodsInfoByRoot', skipped: true, reason: 'hsds_account_busy', lockHolder: lockState.holder || null, }; } else { try { const productPayload = await fetchPlanningHsdsDemandProductStyles({ category: categoryFilter.primaryCategoryName, oneCategory: categoryFilter.primaryCategoryName, secondaryCategory: categoryFilter.secondaryCategoryName, twoCategory: categoryFilter.secondaryCategoryName, tertiaryCategory: categoryFilter.tertiaryCategoryName, threeCategory: categoryFilter.tertiaryCategoryName, rootWord: selectedRow.rootWord, dateShortcut: query.dateShortcut || query.windowType, statDateKey: latestStatDateKey, endDateKey: currentRange.endDateKey, limit: MAX_DEMAND_PRODUCT_ROWS, maxAttempts: 1, requestTimeoutMs: Number(process.env.HSDS_DEMAND_PRODUCT_REQUEST_TIMEOUT_MS) || 12000, requestDelayMs: 0, }); products = Array.isArray(productPayload.products) ? productPayload.products : []; productStylesSourceMeta = { ...(productPayload.sourceMeta || {}), skipped: Boolean(productPayload.skipped), reason: productPayload.reason || '', total: products.length, rootWord: selectedRow.rootWord, }; } catch (error) { productStylesSourceMeta = { source: 'hsds_demand_getGoodsInfoByRoot', skipped: true, reason: 'hsds_request_failed', message: normalizeText(error?.message || error), rootWord: selectedRow.rootWord, }; } finally { await lockState.release(); } } } } const returnedTableRows = buildDemandTablePayloadRows( tableRows, selectedRow?.rootWord || selectedRootWord, query.limit || query.pageSize || query.rowLimit ); const boardRows = buildDemandBoardRowsPayload({ historyRows: tableRows, latestTrendRows: latestDemandTrendRows, hotRows: keywordSignals.hotRows, risingRows: keywordSignals.risingRows, categoryFilter, selectedRootWord: selectedRow?.rootWord || selectedRootWord, rowLimit: query.limit || query.pageSize || query.rowLimit, }); const availableMonths = Array.from(new Set([ ...demandIndexMonths, ...(keywordSignals.availableMonths || []), ])).sort().reverse(); const keywordSummary = { hotKeywordCount: keywordSignals.hotRows.length, risingKeywordCount: keywordSignals.risingRows.length, overlapKeywordCount: keywordRows.filter((row) => row.keywordTypeDisplay === '热搜+飙升').length, latestSnapshotMonth: keywordSignals.latestSnapshotMonth, keywordCount: keywordRows.length, categoryPathLabel: categoryFilter.categoryPathLabel || primaryCategoryName, }; const metricFamily = 'index'; const sourceMeta = { source: DEMAND_INDEX_TABLE, latestStatDateKey, totalWords: tableRows.length, returnedWords: returnedTableRows.length, windowType: currentRange.windowType, trendDays, sortMode: normalizeText(query.sortMode).toLowerCase() === 'trend' ? 'trend' : 'index', demandMode: mode, metricFamily, summaryStrategy: 'dynamic', keywordCategoryPath: categoryFilter.categoryPathLabel || primaryCategoryName, keywordSources: { hot: HOT_SEARCH_WORD_TABLE, rising: RISING_WORD_TABLE, hotSnapshotPeriod: keywordSignals.hotSnapshotPeriod, risingSnapshotPeriod: keywordSignals.risingSnapshotPeriod, skipped: Boolean(keywordSignals.skipped), skipReason: keywordSignals.skipReason || '', }, latestKeywordSnapshotMonth: keywordSignals.latestSnapshotMonth, productStyles: productStylesSourceMeta, fallbackCategory: fallbackCategoryMeta, note: mode === 'keyword' ? '关键词布局分析已接入真实需求指数底表,并补充热搜词/飙升词快照用于词榜归类与趋势核验。' : '需求分析已接入真实需求指数底表,并补充热搜词/飙升词快照用于关键词语境对齐。', }; const summaryMetricBlueprint = buildDemandSummaryMetricBlueprint(metricFamily); const keywordTableColumns = [ { key: 'name', label: '关键词' }, { key: 'action', label: '趋势分析', type: 'action' }, { key: 'keywordTypeDisplay', label: '词榜类型' }, { key: 'searchIndexDisplay', label: '搜索指数' }, { key: 'clickIndexDisplay', label: '点击指数' }, { key: 'payIndexDisplay', label: '支付指数' }, { key: 'clickRateDisplay', label: '点击率' }, { key: 'payConversionRateDisplay', label: '支付转化率' }, ]; const trendTableColumns = [ { key: 'name', label: '关键词' }, { key: 'action', label: '趋势分析', type: 'action' }, { key: 'searchIndexGrowDisplay', label: '搜索指数环比' }, { key: 'searchIndexDisplay', label: '搜索指数' }, { key: 'clickIndexDisplay', label: '点击指数' }, { key: 'payIndexDisplay', label: '支付指数' }, { key: 'clickRateDisplay', label: '点击率' }, { key: 'payConversionRateDisplay', label: '支付转化率' }, { key: 'keywordTypeDisplay', label: '词榜类型' }, ]; const trendMetricOptions = [ { label: '搜索指数', value: 'searchIndex' }, { label: '点击指数', value: 'clickIndex' }, { label: '支付指数', value: 'payIndex' }, { label: '点击率', value: 'clickRate' }, { label: '支付转化率', value: 'payConversionRate' }, ]; return { demand: { header: { platform, primaryCategoryName, windowType: currentRange.windowType, windowLabel: currentRange.windowLabel, latestStatDateKey, statRangeLabel: formatRangeLabel(currentRange.startDateKey, currentRange.endDateKey), startDateKey: currentRange.startDateKey, endDateKey: currentRange.endDateKey, }, summaryMetrics: buildSummaryMetricsForRow(selectedRow, metricFamily), summaryMetricBlueprint, rootWordGroups: topRows.map((row) => ({ rootWord: row.rootWord, searchPeopleDisplay: row.searchIndexDisplay || '--', searchPeople: row.searchPeople, trendStatus: row.trendStatus, })), tableRows: returnedTableRows, boardRows, products, selectedRootWord: selectedRow?.rootWord || selectedRootWord, tableColumns: mode === 'keyword' ? keywordTableColumns : mode === 'trend' ? trendTableColumns : undefined, trendMetricOptions, sourceMeta, panelSubtitle: `${sourceMeta.sortMode === 'trend' ? `按近${trendDays}日增长趋势排序` : '按搜索指数排序'},共 ${sourceMeta.totalWords || tableRows.length} 个关键词,已返回前 ${returnedTableRows.length} 个`, }, trend: [], statMonth: latestStatDateKey.slice(0, 6), effectiveMonthRange: [latestStatDateKey.slice(0, 6), latestStatDateKey.slice(0, 6)], availableMonths, }; } async function getPlanningDemandWordTrend(query = {}) { const categoryFilter = buildDemandCategoryFilter(query); const primaryCategoryName = categoryFilter.primaryCategoryName; const platform = normalizeText(query.platform, DEFAULT_PLATFORM); const rootWord = normalizeText(query.rootWord); const granularity = normalizeTrendGranularity(query.granularity); const mode = normalizeText(query.mode, '').toLowerCase(); if (!rootWord) { const error = new Error('缺少 rootWord 参数'); error.status = 400; throw error; } const latestIndexStatDateKey = await getLatestDemandIndexDateKey(categoryFilter, platform); const trendRange = latestIndexStatDateKey ? resolveWindowRange(query.dateShortcut || query.windowType, latestIndexStatDateKey) : null; const rows = latestIndexStatDateKey ? await getDemandIndexDailyRows({ categoryFilter, platform, startDateKey: trendRange.startDateKey, endDateKey: trendRange.endDateKey, rootWord, }) : []; const points = buildDemandIndexTrendGroups(rows, granularity); return { rootWord, granularity, platform, primaryCategoryName, windowType: trendRange?.windowType || normalizeWindowType(query.dateShortcut || query.windowType), windowLabel: trendRange?.windowLabel || WINDOW_LABEL_MAP[normalizeWindowType(query.dateShortcut || query.windowType)], points, source: DEMAND_INDEX_TABLE, summary: { pointCount: points.length, minStatDateKey: points[0]?.statDateKey || '', maxStatDateKey: points[points.length - 1]?.statDateKey || '', latestSearchIndex: points[points.length - 1]?.searchIndex || 0, missingDedicatedDemandIndex: !latestIndexStatDateKey, }, }; } async function getPlanningDemandKeywordEvidence(query = {}) { const categoryFilter = buildDemandCategoryFilter(query); const platform = normalizeText(query.platform, DEFAULT_PLATFORM); const keywords = Array.from(new Set((Array.isArray(query.keywords) ? query.keywords : []) .map((item) => normalizeText(item)) .filter(Boolean))) .slice(0, 60); if (!keywords.length) { return { rows: [], latestStatDateKey: '', statRangeLabel: '' }; } let effectiveCategoryFilter = categoryFilter; let latestStatDateKey = await getLatestDemandIndexDateKey(effectiveCategoryFilter, platform); if (!latestStatDateKey && categoryFilter.tertiaryCategoryName) { effectiveCategoryFilter = buildDemandCategoryFilter({ category: categoryFilter.primaryCategoryName, oneCategory: categoryFilter.primaryCategoryName, twoCategory: categoryFilter.secondaryCategoryName, }); latestStatDateKey = await getLatestDemandIndexDateKey(effectiveCategoryFilter, platform); } if (!latestStatDateKey) { return { rows: [], latestStatDateKey: '', statRangeLabel: '' }; } const currentRange = resolveWindowRange(query.dateShortcut || DEFAULT_WINDOW_TYPE, latestStatDateKey); const previousRange = buildPreviousRange(currentRange); const trendDays = normalizeDemandTrendDays(query.trendDays); const trendStartDateKey = dayjs(latestStatDateKey, 'YYYYMMDD') .subtract(trendDays - 1, 'day') .format('YYYYMMDD'); const trendQueryStartDateKey = trendStartDateKey < previousRange.startDateKey ? trendStartDateKey : previousRange.startDateKey; const [currentRows, previousRows, trendSeriesRows] = await Promise.all([ getDemandIndexAggregateRows({ categoryFilter: effectiveCategoryFilter, platform, startDateKey: currentRange.startDateKey, endDateKey: currentRange.endDateKey, keywords, limit: 2000, }), getDemandIndexAggregateRows({ categoryFilter: effectiveCategoryFilter, platform, startDateKey: previousRange.startDateKey, endDateKey: previousRange.endDateKey, keywords, limit: 2000, }), getDemandIndexTrendSeriesRows({ categoryFilter: effectiveCategoryFilter, platform, startDateKey: trendQueryStartDateKey, endDateKey: latestStatDateKey, keywords, }), ]); const rows = attachDemandTrendSeries( applyDemandComparableGrowth( mapDemandIndexAggregateRows(currentRows, previousRows, { expectedDays: currentRange.periodDays }), buildDemandComparableGrowthMap(trendSeriesRows, currentRange, previousRange) ), buildDemandTrendSeries(trendSeriesRows, latestStatDateKey, trendDays) ).map(slimDemandTableRow); return { rows, latestStatDateKey, statRangeLabel: formatRangeLabel(currentRange.startDateKey, currentRange.endDateKey), effectiveCategoryPath: effectiveCategoryFilter.categoryPathLabel, }; } module.exports = { DEMAND_INDEX_TABLE, ROOT_WORD_TREND_TABLE, getPlanningDemandWordAnalysis, getPlanningDemandKeywordEvidence, getPlanningDemandWordTrend, __test: { buildDemandTrendSeries, computeComparablePeriodDelta, hasComparablePeriodCoverage, normalizeDemandTrendDays, normalizeWindowType, resolveWindowRange, sortDemandRows, }, };