local-creator-db.service.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. import { mkdir, writeFile } from 'node:fs/promises';
  2. import { Pool } from 'pg';
  3. import type { JustOneCreator } from './justone.service.ts';
  4. import { extractWorkbookTablesFromBuffer } from '../utils/file-parser.ts';
  5. const PROVIDER_ARCHIVE_DIR = 'docs/provider-archives';
  6. const RAW_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
  7. const EMBEDDING_DIMENSION = 384;
  8. const pool = new Pool({
  9. connectionString: process.env.DATABASE_URL || 'postgres://tihao_ai_app:tihao_ai_app@localhost:5432/tihao_ai',
  10. });
  11. interface SearchCriteria {
  12. platforms: string[];
  13. keywords: string[];
  14. fanRange: { min: number; max: number };
  15. budgetRange: { min: number; max: number };
  16. region?: string | string[];
  17. gender?: string;
  18. contentTags?: string[];
  19. excludeTags?: string[];
  20. }
  21. interface ParsedCreator extends JustOneCreator {
  22. sourceKind: 'uploaded' | 'company' | 'confirmed_api';
  23. sourceFile?: string;
  24. embeddingText: string;
  25. sourceConfidence?: number;
  26. }
  27. export async function ingestUploadedCreatorLibrary(buffer: Buffer, fileName: string): Promise<number> {
  28. const sheets = extractWorkbookTablesFromBuffer(buffer);
  29. const creators = sheets.flatMap((sheet) => parseCreatorRows(sheet.rows, fileName));
  30. if (creators.length === 0) return 0;
  31. await upsertCleanCreators(creators);
  32. return creators.length;
  33. }
  34. export async function searchLocalCreators(criteria: SearchCriteria, limit = 200): Promise<JustOneCreator[]> {
  35. const normalized = normalizeCriteria(criteria);
  36. const queryText = buildCriteriaEmbeddingText(normalized);
  37. const embedding = buildEmbedding(queryText);
  38. const platformFilters = normalized.platforms.map(mapPlatform).filter(Boolean);
  39. const params: unknown[] = [embedding, limit];
  40. const where: string[] = [];
  41. if (platformFilters.length > 0) {
  42. params.push(platformFilters);
  43. where.push(`platform = ANY($${params.length}::text[])`);
  44. }
  45. if (normalized.fanRange?.min) {
  46. params.push(normalized.fanRange.min);
  47. where.push(`fans_count >= $${params.length}`);
  48. }
  49. if (normalized.fanRange?.max) {
  50. params.push(normalized.fanRange.max);
  51. where.push(`fans_count <= $${params.length}`);
  52. }
  53. const sql = `
  54. SELECT
  55. *,
  56. cosine_similarity(embedding, $1::double precision[]) AS similarity
  57. FROM creator_profile_clean
  58. ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
  59. ORDER BY
  60. cosine_similarity(embedding, $1::double precision[]) DESC,
  61. updated_at DESC
  62. LIMIT $2
  63. `;
  64. const result = await pool.query(sql, params);
  65. const cleanCreators = result.rows
  66. .map((row) => ({ creator: rowToCreator(row), score: scoreRetrievedCreator(row, normalized) }))
  67. .filter((item) => item.score > 0)
  68. .sort((a, b) => b.score - a.score)
  69. .slice(0, limit)
  70. .map((item) => item.creator);
  71. const cachedProviderCreators = await searchCachedProviderCreators(
  72. normalized,
  73. Math.max(0, limit - cleanCreators.length),
  74. );
  75. return mergeCreators([...cleanCreators, ...cachedProviderCreators]).slice(0, limit);
  76. }
  77. export async function archiveProviderResponse(params: {
  78. provider: string;
  79. endpoint: string;
  80. requestParams: Record<string, unknown>;
  81. responseBody: unknown;
  82. }): Promise<void> {
  83. const fetchedAt = new Date();
  84. const expiresAt = new Date(fetchedAt.getTime() + RAW_CACHE_TTL_MS);
  85. const safeEndpoint = params.endpoint.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 80);
  86. const archiveDir = `${PROVIDER_ARCHIVE_DIR}/${fetchedAt.toISOString().slice(0, 7)}`;
  87. await mkdir(archiveDir, { recursive: true });
  88. const id = `${params.provider}_${fetchedAt.getTime()}_${Math.random().toString(36).slice(2, 8)}`;
  89. const archivePath = `${archiveDir}/${id}_${safeEndpoint}.json`;
  90. await writeFile(archivePath, JSON.stringify(params, null, 2), 'utf-8');
  91. await pool.query(
  92. `INSERT INTO provider_raw_cache
  93. (provider, endpoint, request_params, response_body, fetched_at, expires_at, normalized_status, archive_path)
  94. VALUES ($1, $2, $3::jsonb, $4::jsonb, $5, $6, 'pending', $7)`,
  95. [
  96. params.provider,
  97. params.endpoint,
  98. JSON.stringify(params.requestParams),
  99. JSON.stringify(params.responseBody),
  100. fetchedAt,
  101. expiresAt,
  102. archivePath,
  103. ],
  104. );
  105. }
  106. export async function cacheProviderCreators(params: {
  107. provider: string;
  108. endpoint: string;
  109. requestParams: Record<string, unknown>;
  110. creators: JustOneCreator[];
  111. }): Promise<number> {
  112. const creators = params.creators.filter((creator) => creator.userId && creator.nickname);
  113. if (creators.length === 0) return 0;
  114. const fetchedAt = new Date();
  115. const expiresAt = new Date(fetchedAt.getTime() + RAW_CACHE_TTL_MS);
  116. const client = await pool.connect();
  117. try {
  118. await client.query('BEGIN');
  119. for (const creator of creators) {
  120. const embeddingText = buildCreatorEmbeddingText(creator);
  121. await client.query(
  122. `INSERT INTO provider_creator_raw_cache (
  123. provider, endpoint, request_params, platform, platform_user_id, display_name,
  124. fans_count, liked_collect_count, content_type, content_tags, persona_tags,
  125. city, geo_location, profile_url, cooperation_method, image_price, video_price,
  126. min_price, cooperation_status, raw_creator, fetched_at, expires_at,
  127. normalized_status, embedding_text, embedding
  128. ) VALUES (
  129. $1, $2, $3::jsonb, $4, $5, $6,
  130. $7, $8, $9, $10::jsonb, $11::jsonb,
  131. $12, $13, $14, $15, $16, $17,
  132. $18, $19, $20::jsonb, $21, $22,
  133. 'raw', $23, $24::double precision[]
  134. )
  135. ON CONFLICT (provider, platform, platform_user_id) DO UPDATE SET
  136. endpoint = EXCLUDED.endpoint,
  137. request_params = EXCLUDED.request_params,
  138. display_name = EXCLUDED.display_name,
  139. fans_count = EXCLUDED.fans_count,
  140. liked_collect_count = EXCLUDED.liked_collect_count,
  141. content_type = EXCLUDED.content_type,
  142. content_tags = EXCLUDED.content_tags,
  143. persona_tags = EXCLUDED.persona_tags,
  144. city = EXCLUDED.city,
  145. geo_location = EXCLUDED.geo_location,
  146. profile_url = EXCLUDED.profile_url,
  147. cooperation_method = EXCLUDED.cooperation_method,
  148. image_price = EXCLUDED.image_price,
  149. video_price = EXCLUDED.video_price,
  150. min_price = EXCLUDED.min_price,
  151. cooperation_status = EXCLUDED.cooperation_status,
  152. raw_creator = EXCLUDED.raw_creator,
  153. fetched_at = EXCLUDED.fetched_at,
  154. expires_at = EXCLUDED.expires_at,
  155. normalized_status = 'raw',
  156. embedding_text = EXCLUDED.embedding_text,
  157. embedding = EXCLUDED.embedding`,
  158. [
  159. params.provider,
  160. params.endpoint,
  161. JSON.stringify(params.requestParams),
  162. creator.platform,
  163. creator.userId,
  164. creator.nickname,
  165. creator.fansCount || 0,
  166. creator.likedCollectCount || 0,
  167. creator.contentType || creator.contentTags.join('、'),
  168. JSON.stringify(creator.contentTags || []),
  169. JSON.stringify(creator.personalTags || []),
  170. creator.city || creator.location || null,
  171. creator.geoLocation || creator.location || null,
  172. getCreatorProfileUrl(creator),
  173. creator.cooperationMethod || null,
  174. creator.imagePrice || 0,
  175. creator.videoPrice || 0,
  176. creator.minPrice || 0,
  177. creator.cooperationStatus || null,
  178. JSON.stringify(creator),
  179. fetchedAt,
  180. expiresAt,
  181. embeddingText,
  182. buildEmbedding(embeddingText),
  183. ],
  184. );
  185. }
  186. await client.query('COMMIT');
  187. return creators.length;
  188. } catch (error) {
  189. await client.query('ROLLBACK');
  190. throw error;
  191. } finally {
  192. client.release();
  193. }
  194. }
  195. export async function recordRetrievalEvent(params: {
  196. taskId?: string;
  197. queryText: string;
  198. criteria: unknown;
  199. localHitCount: number;
  200. providerHitCount: number;
  201. finalCount: number;
  202. }): Promise<void> {
  203. await pool.query(
  204. `INSERT INTO creator_retrieval_event
  205. (task_id, query_text, criteria, local_hit_count, provider_hit_count, final_count)
  206. VALUES ($1, $2, $3::jsonb, $4, $5, $6)`,
  207. [
  208. params.taskId || null,
  209. params.queryText,
  210. JSON.stringify(params.criteria || {}),
  211. params.localHitCount,
  212. params.providerHitCount,
  213. params.finalCount,
  214. ],
  215. );
  216. }
  217. async function upsertCleanCreators(creators: ParsedCreator[]): Promise<void> {
  218. const client = await pool.connect();
  219. try {
  220. await client.query('BEGIN');
  221. for (const creator of creators) {
  222. const embeddingText = creator.embeddingText || buildCreatorEmbeddingText(creator);
  223. await client.query(
  224. `INSERT INTO creator_profile_clean (
  225. platform, platform_user_id, display_name, gender, fans_count, liked_collect_count,
  226. content_type, content_tags, persona_tags, city, geo_location, profile_url,
  227. cooperation_method, image_price, video_price, min_price, cooperation_status,
  228. source_kind, source_provider, source_file, source_confidence, updated_at,
  229. embedding_text, embedding
  230. ) VALUES (
  231. $1, $2, $3, $4, $5, $6,
  232. $7, $8::jsonb, $9::jsonb, $10, $11, $12,
  233. $13, $14, $15, $16, $17,
  234. $18, $19, $20, $21, now(),
  235. $22, $23::double precision[]
  236. )
  237. ON CONFLICT (platform, platform_user_id) DO UPDATE SET
  238. display_name = EXCLUDED.display_name,
  239. gender = EXCLUDED.gender,
  240. fans_count = EXCLUDED.fans_count,
  241. liked_collect_count = EXCLUDED.liked_collect_count,
  242. content_type = EXCLUDED.content_type,
  243. content_tags = EXCLUDED.content_tags,
  244. persona_tags = EXCLUDED.persona_tags,
  245. city = EXCLUDED.city,
  246. geo_location = EXCLUDED.geo_location,
  247. profile_url = EXCLUDED.profile_url,
  248. cooperation_method = EXCLUDED.cooperation_method,
  249. image_price = EXCLUDED.image_price,
  250. video_price = EXCLUDED.video_price,
  251. min_price = EXCLUDED.min_price,
  252. cooperation_status = EXCLUDED.cooperation_status,
  253. source_kind = EXCLUDED.source_kind,
  254. source_provider = EXCLUDED.source_provider,
  255. source_file = EXCLUDED.source_file,
  256. source_confidence = EXCLUDED.source_confidence,
  257. updated_at = now(),
  258. embedding_text = EXCLUDED.embedding_text,
  259. embedding = EXCLUDED.embedding`,
  260. [
  261. creator.platform,
  262. creator.userId,
  263. creator.nickname,
  264. creator.gender || null,
  265. creator.fansCount || 0,
  266. creator.likedCollectCount || 0,
  267. creator.contentType || creator.contentTags.join('、'),
  268. JSON.stringify(creator.contentTags || []),
  269. JSON.stringify(creator.personalTags || []),
  270. creator.city || creator.location || null,
  271. creator.geoLocation || creator.location || null,
  272. creator.xiaohongshuUrl || null,
  273. creator.cooperationMethod || null,
  274. creator.imagePrice || 0,
  275. creator.videoPrice || 0,
  276. creator.minPrice || 0,
  277. creator.cooperationStatus || null,
  278. creator.sourceKind,
  279. creator.sourceProvider || 'local-upload',
  280. creator.sourceFile || null,
  281. creator.sourceConfidence || 90,
  282. embeddingText,
  283. buildEmbedding(embeddingText),
  284. ],
  285. );
  286. }
  287. await client.query('COMMIT');
  288. } catch (error) {
  289. await client.query('ROLLBACK');
  290. throw error;
  291. } finally {
  292. client.release();
  293. }
  294. }
  295. async function searchCachedProviderCreators(criteria: SearchCriteria, limit: number): Promise<JustOneCreator[]> {
  296. if (limit <= 0) return [];
  297. const queryText = buildCriteriaEmbeddingText(criteria);
  298. const embedding = buildEmbedding(queryText);
  299. const platformFilters = criteria.platforms.map(mapPlatform).filter(Boolean);
  300. const params: unknown[] = [embedding, limit];
  301. const where: string[] = ['expires_at > now()'];
  302. if (platformFilters.length > 0) {
  303. params.push(platformFilters);
  304. where.push(`platform = ANY($${params.length}::text[])`);
  305. }
  306. if (criteria.fanRange?.min) {
  307. params.push(criteria.fanRange.min);
  308. where.push(`fans_count >= $${params.length}`);
  309. }
  310. if (criteria.fanRange?.max) {
  311. params.push(criteria.fanRange.max);
  312. where.push(`fans_count <= $${params.length}`);
  313. }
  314. const result = await pool.query(
  315. `SELECT
  316. *,
  317. cosine_similarity(embedding, $1::double precision[]) AS similarity
  318. FROM provider_creator_raw_cache
  319. WHERE ${where.join(' AND ')}
  320. ORDER BY
  321. cosine_similarity(embedding, $1::double precision[]) DESC,
  322. fetched_at DESC
  323. LIMIT $2`,
  324. params,
  325. );
  326. return result.rows
  327. .map((row) => ({ creator: rowToProviderRawCreator(row), score: scoreRetrievedCreator(row, criteria) }))
  328. .filter((item) => item.score > 0)
  329. .sort((a, b) => b.score - a.score)
  330. .map((item) => item.creator);
  331. }
  332. function parseCreatorRows(rows: string[][], sourceFile: string): ParsedCreator[] {
  333. const headerIndex = rows.findIndex((row) => row.some((cell) => normalizeHeader(cell) === '昵称'));
  334. if (headerIndex < 0) return [];
  335. const headers = rows[headerIndex].map(normalizeHeader);
  336. const getIndex = (...names: string[]) => headers.findIndex((header) => names.includes(header));
  337. const indexMap = {
  338. nickname: getIndex('昵称', '账号名称'),
  339. gender: getIndex('性别'),
  340. fansWan: getIndex('粉丝数(万)', '粉丝数'),
  341. likedWan: getIndex('赞藏数(万)', '赞藏数'),
  342. contentType: getIndex('内容类型'),
  343. city: getIndex('城市'),
  344. geoLocation: getIndex('地理位置'),
  345. profileUrl: getIndex('小红书主页', '主页链接'),
  346. cooperationMethod: getIndex('合作方式'),
  347. imagePrice: headers.findIndex((header) => header.includes('图文') && header.includes('报价')),
  348. videoPrice: headers.findIndex((header) => header.includes('视频') && header.includes('报价')),
  349. };
  350. if (indexMap.nickname < 0 || indexMap.profileUrl < 0) return [];
  351. return rows.slice(headerIndex + 1)
  352. .filter((row) => row[indexMap.nickname])
  353. .map((row) => {
  354. const nickname = row[indexMap.nickname] || '';
  355. const profileUrl = row[indexMap.profileUrl] || '';
  356. const userId = extractProfileId(profileUrl) || hashText(`${sourceFile}:${nickname}:${profileUrl}`);
  357. const contentType = row[indexMap.contentType] || '';
  358. const imagePrice = toNumber(row[indexMap.imagePrice]);
  359. const videoPrice = toNumber(row[indexMap.videoPrice]);
  360. const fansCount = toCountFromWan(row[indexMap.fansWan]);
  361. const likedCollectCount = toCountFromWan(row[indexMap.likedWan]);
  362. const city = row[indexMap.city] || '';
  363. const geoLocation = row[indexMap.geoLocation] || '';
  364. const contentTags = splitTags(contentType);
  365. const creator: ParsedCreator = {
  366. userId,
  367. platform: 'xiaohongshu',
  368. nickname,
  369. redId: userId,
  370. location: city || geoLocation,
  371. fansCount,
  372. imagePrice,
  373. videoPrice,
  374. minPrice: Math.min(...[imagePrice, videoPrice].filter((price) => price > 0)) || 0,
  375. cooperationStatus: row[indexMap.cooperationMethod] ? 'active' : '',
  376. personalTags: [],
  377. contentTags,
  378. sourceProvider: 'local-upload',
  379. sourceKind: 'uploaded',
  380. sourceFile,
  381. gender: row[indexMap.gender] || '',
  382. likedCollectCount,
  383. contentType,
  384. city,
  385. geoLocation,
  386. xiaohongshuUrl: profileUrl,
  387. cooperationMethod: row[indexMap.cooperationMethod] || '',
  388. embeddingText: '',
  389. };
  390. creator.embeddingText = buildCreatorEmbeddingText(creator);
  391. return creator;
  392. });
  393. }
  394. function rowToCreator(row: Record<string, unknown>): JustOneCreator {
  395. return {
  396. userId: String(row.platform_user_id || ''),
  397. platform: String(row.platform || ''),
  398. nickname: String(row.display_name || ''),
  399. redId: String(row.platform_user_id || ''),
  400. location: String(row.city || row.geo_location || ''),
  401. fansCount: Number(row.fans_count || 0),
  402. imagePrice: Number(row.image_price || 0),
  403. videoPrice: Number(row.video_price || 0),
  404. minPrice: Number(row.min_price || 0),
  405. cooperationStatus: String(row.cooperation_status || ''),
  406. personalTags: Array.isArray(row.persona_tags) ? row.persona_tags as string[] : [],
  407. contentTags: Array.isArray(row.content_tags) ? row.content_tags as string[] : [],
  408. sourceProvider: String(row.source_provider || 'local-db'),
  409. gender: String(row.gender || ''),
  410. likedCollectCount: Number(row.liked_collect_count || 0),
  411. contentType: String(row.content_type || ''),
  412. city: String(row.city || ''),
  413. geoLocation: String(row.geo_location || ''),
  414. xiaohongshuUrl: String(row.profile_url || ''),
  415. cooperationMethod: String(row.cooperation_method || ''),
  416. };
  417. }
  418. function rowToProviderRawCreator(row: Record<string, unknown>): JustOneCreator {
  419. return {
  420. userId: String(row.platform_user_id || ''),
  421. platform: String(row.platform || ''),
  422. nickname: String(row.display_name || ''),
  423. redId: String(row.platform_user_id || ''),
  424. location: String(row.city || row.geo_location || ''),
  425. fansCount: Number(row.fans_count || 0),
  426. imagePrice: Number(row.image_price || 0),
  427. videoPrice: Number(row.video_price || 0),
  428. minPrice: Number(row.min_price || 0),
  429. cooperationStatus: String(row.cooperation_status || ''),
  430. personalTags: Array.isArray(row.persona_tags) ? row.persona_tags as string[] : [],
  431. contentTags: Array.isArray(row.content_tags) ? row.content_tags as string[] : [],
  432. sourceProvider: `${String(row.provider || 'provider')}-raw-cache`,
  433. gender: '',
  434. likedCollectCount: Number(row.liked_collect_count || 0),
  435. contentType: String(row.content_type || ''),
  436. city: String(row.city || ''),
  437. geoLocation: String(row.geo_location || ''),
  438. xiaohongshuUrl: String(row.profile_url || ''),
  439. cooperationMethod: String(row.cooperation_method || ''),
  440. };
  441. }
  442. function mergeCreators(creators: JustOneCreator[]): JustOneCreator[] {
  443. const map = new Map<string, JustOneCreator>();
  444. for (const creator of creators) {
  445. const key = `${creator.platform}:${creator.userId}`;
  446. if (!map.has(key)) map.set(key, creator);
  447. }
  448. return Array.from(map.values());
  449. }
  450. function scoreRetrievedCreator(row: Record<string, unknown>, criteria: SearchCriteria): number {
  451. let score = Number(row.similarity || 0) * 70;
  452. const text = String(row.embedding_text || '').toLowerCase();
  453. const keywords = [...(criteria.contentTags || []), ...(criteria.keywords || [])].map((item) => item.toLowerCase());
  454. const regions = normalizeRegions(criteria.region);
  455. for (const keyword of keywords) {
  456. if (keyword && text.includes(keyword)) score += 12;
  457. }
  458. for (const region of regions) {
  459. if (region && text.includes(region.toLowerCase())) score += 10;
  460. }
  461. for (const exclude of criteria.excludeTags || []) {
  462. if (exclude && text.includes(exclude.toLowerCase())) score -= 4;
  463. }
  464. if (String(row.cooperation_method || '').includes('视频')) score += 8;
  465. if (Number(row.video_price || 0) <= (criteria.budgetRange?.max || Infinity)) score += 6;
  466. return score;
  467. }
  468. function buildCriteriaEmbeddingText(criteria: SearchCriteria): string {
  469. return [
  470. ...(criteria.keywords || []),
  471. ...(criteria.contentTags || []),
  472. ...normalizeRegions(criteria.region),
  473. criteria.gender || '',
  474. `粉丝${criteria.fanRange?.min || 0}-${criteria.fanRange?.max || 0}`,
  475. `预算${criteria.budgetRange?.min || 0}-${criteria.budgetRange?.max || 0}`,
  476. ].filter(Boolean).join(' ');
  477. }
  478. function buildCreatorEmbeddingText(creator: JustOneCreator): string {
  479. return [
  480. creator.nickname,
  481. creator.contentType,
  482. ...(creator.contentTags || []),
  483. ...(creator.personalTags || []),
  484. creator.city,
  485. creator.geoLocation,
  486. creator.gender,
  487. creator.cooperationMethod,
  488. `粉丝${creator.fansCount}`,
  489. `图文报价${creator.imagePrice}`,
  490. `视频报价${creator.videoPrice}`,
  491. ].filter(Boolean).join(' ');
  492. }
  493. function getCreatorProfileUrl(creator: JustOneCreator): string {
  494. if (creator.xiaohongshuUrl) return creator.xiaohongshuUrl;
  495. if (creator.platform === 'douyin') return `https://www.douyin.com/user/${creator.userId}`;
  496. if (creator.platform === 'xiaohongshu') return `https://www.xiaohongshu.com/user/profile/${creator.userId}`;
  497. return '';
  498. }
  499. function buildEmbedding(text: string): number[] {
  500. const vector = new Array(EMBEDDING_DIMENSION).fill(0);
  501. const normalized = text.toLowerCase().replace(/\s+/g, ' ').trim();
  502. const grams = new Set<string>();
  503. for (const token of normalized.split(/[ ,,、/|]+/).filter(Boolean)) {
  504. grams.add(token);
  505. for (let size = 2; size <= 4; size++) {
  506. for (let i = 0; i <= token.length - size; i++) {
  507. grams.add(token.slice(i, i + size));
  508. }
  509. }
  510. }
  511. for (const gram of grams) {
  512. const index = stableHash(gram) % EMBEDDING_DIMENSION;
  513. vector[index] += 1;
  514. }
  515. const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
  516. return norm > 0 ? vector.map((value) => Number((value / norm).toFixed(8))) : vector;
  517. }
  518. function normalizeCriteria(criteria: SearchCriteria): SearchCriteria {
  519. const maxFans = Number(criteria.fanRange?.max || 0);
  520. return {
  521. ...criteria,
  522. fanRange: maxFans > 0 && maxFans <= 1000
  523. ? { min: Number(criteria.fanRange?.min || 0) * 10000, max: maxFans * 10000 }
  524. : criteria.fanRange,
  525. };
  526. }
  527. function normalizeHeader(value: string): string {
  528. return String(value || '').replace(/\s+/g, '').replace(/&#10;/g, '').trim();
  529. }
  530. function normalizeRegions(region?: string | string[]): string[] {
  531. if (!region) return [];
  532. return (Array.isArray(region) ? region : String(region).split(/[、,,/]/))
  533. .map((item) => item.trim())
  534. .filter(Boolean);
  535. }
  536. function mapPlatform(platform: string): string {
  537. if (platform === '小红书') return 'xiaohongshu';
  538. if (platform === '抖音') return 'douyin';
  539. return platform;
  540. }
  541. function splitTags(value: string): string[] {
  542. return String(value || '').split(/[、,,/]/).map((item) => item.trim()).filter(Boolean);
  543. }
  544. function extractProfileId(url: string): string {
  545. return String(url || '').split('/').filter(Boolean).pop() || '';
  546. }
  547. function toNumber(value: unknown): number {
  548. if (typeof value === 'number') return value;
  549. const text = String(value || '').replace(/[¥,,]/g, '').trim();
  550. if (!text || text === '/') return 0;
  551. return Number(text) || 0;
  552. }
  553. function toCountFromWan(value: unknown): number {
  554. const num = toNumber(value);
  555. return num > 0 && num < 10000 ? Math.round(num * 10000) : Math.round(num);
  556. }
  557. function hashText(text: string): string {
  558. return stableHash(text).toString(36);
  559. }
  560. function stableHash(text: string): number {
  561. let hash = 2166136261;
  562. for (let i = 0; i < text.length; i++) {
  563. hash ^= text.charCodeAt(i);
  564. hash = Math.imul(hash, 16777619);
  565. }
  566. return Math.abs(hash >>> 0);
  567. }