forked from lunanting/cangku
2210 lines
84 KiB
JavaScript
2210 lines
84 KiB
JavaScript
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,
|
||
},
|
||
};
|