cangku/backend/src/services/planningDemandWordAnalysisService.js

2210 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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