local-creator-db.service.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  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 = COALESCE(EXCLUDED.profile_url, provider_creator_raw_cache.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 = COALESCE(provider_creator_raw_cache.raw_creator, '{}'::jsonb) || 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. export async function updateProviderCreatorProfileUrl(params: {
  218. provider: string;
  219. platform: string;
  220. platformUserId: string;
  221. profileUrl: string;
  222. secUid?: string;
  223. uniqueId?: string;
  224. }): Promise<void> {
  225. await pool.query(
  226. `UPDATE provider_creator_raw_cache
  227. SET
  228. profile_url = $4,
  229. raw_creator = coalesce(raw_creator, '{}'::jsonb) || $5::jsonb
  230. WHERE provider = $1
  231. AND platform = $2
  232. AND platform_user_id = $3`,
  233. [
  234. params.provider,
  235. params.platform,
  236. params.platformUserId,
  237. params.profileUrl,
  238. JSON.stringify({
  239. profileUrl: params.profileUrl,
  240. secUid: params.secUid || '',
  241. uniqueId: params.uniqueId || '',
  242. }),
  243. ],
  244. );
  245. }
  246. async function upsertCleanCreators(creators: ParsedCreator[]): Promise<void> {
  247. const client = await pool.connect();
  248. try {
  249. await client.query('BEGIN');
  250. for (const creator of creators) {
  251. const embeddingText = creator.embeddingText || buildCreatorEmbeddingText(creator);
  252. await client.query(
  253. `INSERT INTO creator_profile_clean (
  254. platform, platform_user_id, display_name, gender, fans_count, liked_collect_count,
  255. content_type, content_tags, persona_tags, city, geo_location, profile_url,
  256. cooperation_method, image_price, video_price, min_price, cooperation_status,
  257. source_kind, source_provider, source_file, source_confidence, updated_at,
  258. embedding_text, embedding
  259. ) VALUES (
  260. $1, $2, $3, $4, $5, $6,
  261. $7, $8::jsonb, $9::jsonb, $10, $11, $12,
  262. $13, $14, $15, $16, $17,
  263. $18, $19, $20, $21, now(),
  264. $22, $23::double precision[]
  265. )
  266. ON CONFLICT (platform, platform_user_id) DO UPDATE SET
  267. display_name = EXCLUDED.display_name,
  268. gender = EXCLUDED.gender,
  269. fans_count = EXCLUDED.fans_count,
  270. liked_collect_count = EXCLUDED.liked_collect_count,
  271. content_type = EXCLUDED.content_type,
  272. content_tags = EXCLUDED.content_tags,
  273. persona_tags = EXCLUDED.persona_tags,
  274. city = EXCLUDED.city,
  275. geo_location = EXCLUDED.geo_location,
  276. profile_url = EXCLUDED.profile_url,
  277. cooperation_method = EXCLUDED.cooperation_method,
  278. image_price = EXCLUDED.image_price,
  279. video_price = EXCLUDED.video_price,
  280. min_price = EXCLUDED.min_price,
  281. cooperation_status = EXCLUDED.cooperation_status,
  282. source_kind = EXCLUDED.source_kind,
  283. source_provider = EXCLUDED.source_provider,
  284. source_file = EXCLUDED.source_file,
  285. source_confidence = EXCLUDED.source_confidence,
  286. updated_at = now(),
  287. embedding_text = EXCLUDED.embedding_text,
  288. embedding = EXCLUDED.embedding`,
  289. [
  290. creator.platform,
  291. creator.userId,
  292. creator.nickname,
  293. creator.gender || null,
  294. creator.fansCount || 0,
  295. creator.likedCollectCount || 0,
  296. creator.contentType || creator.contentTags.join('、'),
  297. JSON.stringify(creator.contentTags || []),
  298. JSON.stringify(creator.personalTags || []),
  299. creator.city || creator.location || null,
  300. creator.geoLocation || creator.location || null,
  301. creator.xiaohongshuUrl || null,
  302. creator.cooperationMethod || null,
  303. creator.imagePrice || 0,
  304. creator.videoPrice || 0,
  305. creator.minPrice || 0,
  306. creator.cooperationStatus || null,
  307. creator.sourceKind,
  308. creator.sourceProvider || 'local-upload',
  309. creator.sourceFile || null,
  310. creator.sourceConfidence || 90,
  311. embeddingText,
  312. buildEmbedding(embeddingText),
  313. ],
  314. );
  315. }
  316. await client.query('COMMIT');
  317. } catch (error) {
  318. await client.query('ROLLBACK');
  319. throw error;
  320. } finally {
  321. client.release();
  322. }
  323. }
  324. async function searchCachedProviderCreators(criteria: SearchCriteria, limit: number): Promise<JustOneCreator[]> {
  325. if (limit <= 0) return [];
  326. const queryText = buildCriteriaEmbeddingText(criteria);
  327. const embedding = buildEmbedding(queryText);
  328. const platformFilters = criteria.platforms.map(mapPlatform).filter(Boolean);
  329. const params: unknown[] = [embedding, limit];
  330. const where: string[] = ['expires_at > now()'];
  331. if (platformFilters.length > 0) {
  332. params.push(platformFilters);
  333. where.push(`platform = ANY($${params.length}::text[])`);
  334. }
  335. if (criteria.fanRange?.min) {
  336. params.push(criteria.fanRange.min);
  337. where.push(`fans_count >= $${params.length}`);
  338. }
  339. if (criteria.fanRange?.max) {
  340. params.push(criteria.fanRange.max);
  341. where.push(`fans_count <= $${params.length}`);
  342. }
  343. const result = await pool.query(
  344. `SELECT
  345. *,
  346. cosine_similarity(embedding, $1::double precision[]) AS similarity
  347. FROM provider_creator_raw_cache
  348. WHERE ${where.join(' AND ')}
  349. ORDER BY
  350. cosine_similarity(embedding, $1::double precision[]) DESC,
  351. fetched_at DESC
  352. LIMIT $2`,
  353. params,
  354. );
  355. return result.rows
  356. .map((row) => ({ creator: rowToProviderRawCreator(row), score: scoreRetrievedCreator(row, criteria) }))
  357. .filter((item) => item.score > 0)
  358. .sort((a, b) => b.score - a.score)
  359. .map((item) => item.creator);
  360. }
  361. function parseCreatorRows(rows: string[][], sourceFile: string): ParsedCreator[] {
  362. const headerIndex = rows.findIndex((row) => row.some((cell) => normalizeHeader(cell) === '昵称'));
  363. if (headerIndex < 0) return [];
  364. const headers = rows[headerIndex].map(normalizeHeader);
  365. const getIndex = (...names: string[]) => headers.findIndex((header) => names.includes(header));
  366. const indexMap = {
  367. nickname: getIndex('昵称', '账号名称'),
  368. gender: getIndex('性别'),
  369. fansWan: getIndex('粉丝数(万)', '粉丝数'),
  370. likedWan: getIndex('赞藏数(万)', '赞藏数'),
  371. contentType: getIndex('内容类型'),
  372. city: getIndex('城市'),
  373. geoLocation: getIndex('地理位置'),
  374. profileUrl: getIndex('小红书主页', '主页链接'),
  375. cooperationMethod: getIndex('合作方式'),
  376. imagePrice: headers.findIndex((header) => header.includes('图文') && header.includes('报价')),
  377. videoPrice: headers.findIndex((header) => header.includes('视频') && header.includes('报价')),
  378. };
  379. if (indexMap.nickname < 0 || indexMap.profileUrl < 0) return [];
  380. return rows.slice(headerIndex + 1)
  381. .filter((row) => row[indexMap.nickname])
  382. .map((row) => {
  383. const nickname = row[indexMap.nickname] || '';
  384. const profileUrl = row[indexMap.profileUrl] || '';
  385. const userId = extractProfileId(profileUrl) || hashText(`${sourceFile}:${nickname}:${profileUrl}`);
  386. const contentType = row[indexMap.contentType] || '';
  387. const imagePrice = toNumber(row[indexMap.imagePrice]);
  388. const videoPrice = toNumber(row[indexMap.videoPrice]);
  389. const fansCount = toCountFromWan(row[indexMap.fansWan]);
  390. const likedCollectCount = toCountFromWan(row[indexMap.likedWan]);
  391. const city = row[indexMap.city] || '';
  392. const geoLocation = row[indexMap.geoLocation] || '';
  393. const contentTags = splitTags(contentType);
  394. const creator: ParsedCreator = {
  395. userId,
  396. platform: 'xiaohongshu',
  397. nickname,
  398. redId: userId,
  399. location: city || geoLocation,
  400. fansCount,
  401. imagePrice,
  402. videoPrice,
  403. minPrice: Math.min(...[imagePrice, videoPrice].filter((price) => price > 0)) || 0,
  404. cooperationStatus: row[indexMap.cooperationMethod] ? 'active' : '',
  405. personalTags: [],
  406. contentTags,
  407. sourceProvider: 'local-upload',
  408. sourceKind: 'uploaded',
  409. sourceFile,
  410. gender: row[indexMap.gender] || '',
  411. likedCollectCount,
  412. contentType,
  413. city,
  414. geoLocation,
  415. xiaohongshuUrl: profileUrl,
  416. cooperationMethod: row[indexMap.cooperationMethod] || '',
  417. embeddingText: '',
  418. };
  419. creator.embeddingText = buildCreatorEmbeddingText(creator);
  420. return creator;
  421. });
  422. }
  423. function rowToCreator(row: Record<string, unknown>): JustOneCreator {
  424. return {
  425. userId: String(row.platform_user_id || ''),
  426. platform: String(row.platform || ''),
  427. nickname: String(row.display_name || ''),
  428. redId: String(row.platform_user_id || ''),
  429. location: String(row.city || row.geo_location || ''),
  430. fansCount: Number(row.fans_count || 0),
  431. imagePrice: Number(row.image_price || 0),
  432. videoPrice: Number(row.video_price || 0),
  433. minPrice: Number(row.min_price || 0),
  434. cooperationStatus: String(row.cooperation_status || ''),
  435. personalTags: Array.isArray(row.persona_tags) ? row.persona_tags as string[] : [],
  436. contentTags: Array.isArray(row.content_tags) ? row.content_tags as string[] : [],
  437. sourceProvider: String(row.source_provider || 'local-db'),
  438. gender: String(row.gender || ''),
  439. likedCollectCount: Number(row.liked_collect_count || 0),
  440. contentType: String(row.content_type || ''),
  441. city: String(row.city || ''),
  442. geoLocation: String(row.geo_location || ''),
  443. profileUrl: String(row.profile_url || ''),
  444. xiaohongshuUrl: String(row.platform || '') === 'xiaohongshu' ? String(row.profile_url || '') : '',
  445. cooperationMethod: String(row.cooperation_method || ''),
  446. };
  447. }
  448. function rowToProviderRawCreator(row: Record<string, unknown>): JustOneCreator {
  449. const rawCreator = parseRawCreator(row.raw_creator);
  450. return {
  451. userId: String(row.platform_user_id || ''),
  452. platform: String(row.platform || ''),
  453. nickname: String(row.display_name || ''),
  454. redId: String(row.platform_user_id || ''),
  455. location: String(row.city || row.geo_location || ''),
  456. fansCount: Number(row.fans_count || 0),
  457. imagePrice: Number(row.image_price || 0),
  458. videoPrice: Number(row.video_price || 0),
  459. minPrice: Number(row.min_price || 0),
  460. cooperationStatus: String(row.cooperation_status || ''),
  461. personalTags: Array.isArray(row.persona_tags) ? row.persona_tags as string[] : [],
  462. contentTags: Array.isArray(row.content_tags) ? row.content_tags as string[] : [],
  463. sourceProvider: `${String(row.provider || 'provider')}-raw-cache`,
  464. gender: '',
  465. likedCollectCount: Number(row.liked_collect_count || 0),
  466. contentType: String(row.content_type || ''),
  467. city: String(row.city || ''),
  468. geoLocation: String(row.geo_location || ''),
  469. profileUrl: String(row.profile_url || rawCreator.profileUrl || ''),
  470. coreUserId: String(rawCreator.coreUserId || ''),
  471. secUid: String(rawCreator.secUid || ''),
  472. uniqueId: String(rawCreator.uniqueId || ''),
  473. xiaohongshuUrl: String(row.platform || '') === 'xiaohongshu' ? String(row.profile_url || '') : '',
  474. cooperationMethod: String(row.cooperation_method || ''),
  475. };
  476. }
  477. function parseRawCreator(value: unknown): Record<string, unknown> {
  478. if (typeof value === 'object' && value !== null) return value as Record<string, unknown>;
  479. if (typeof value !== 'string') return {};
  480. try {
  481. const parsed = JSON.parse(value);
  482. return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : {};
  483. } catch {
  484. return {};
  485. }
  486. }
  487. function mergeCreators(creators: JustOneCreator[]): JustOneCreator[] {
  488. const map = new Map<string, JustOneCreator>();
  489. for (const creator of creators) {
  490. const key = `${creator.platform}:${creator.userId}`;
  491. if (!map.has(key)) map.set(key, creator);
  492. }
  493. return Array.from(map.values());
  494. }
  495. function scoreRetrievedCreator(row: Record<string, unknown>, criteria: SearchCriteria): number {
  496. let score = Number(row.similarity || 0) * 70;
  497. const text = String(row.embedding_text || '').toLowerCase();
  498. const keywords = [...(criteria.contentTags || []), ...(criteria.keywords || [])].map((item) => item.toLowerCase());
  499. const regions = normalizeRegions(criteria.region);
  500. const meaningfulKeywords = keywords.filter((keyword) => isMeaningfulKeyword(keyword));
  501. const hasMeaningfulKeywordMatch = meaningfulKeywords.length === 0
  502. || meaningfulKeywords.some((keyword) => text.includes(keyword) || extractKeywordTerms(keyword).some((term) => text.includes(term)));
  503. if (!hasMeaningfulKeywordMatch) return 0;
  504. for (const keyword of keywords) {
  505. if (keyword && text.includes(keyword)) score += 12;
  506. }
  507. for (const region of regions) {
  508. if (region && text.includes(region.toLowerCase())) score += 10;
  509. }
  510. for (const exclude of criteria.excludeTags || []) {
  511. if (exclude && text.includes(exclude.toLowerCase())) score -= 4;
  512. }
  513. if (String(row.cooperation_method || '').includes('视频')) score += 8;
  514. if (Number(row.video_price || 0) <= (criteria.budgetRange?.max || Infinity)) score += 6;
  515. return score;
  516. }
  517. function buildCriteriaEmbeddingText(criteria: SearchCriteria): string {
  518. return [
  519. ...(criteria.keywords || []),
  520. ...(criteria.contentTags || []),
  521. ...normalizeRegions(criteria.region),
  522. criteria.gender || '',
  523. `粉丝${criteria.fanRange?.min || 0}-${criteria.fanRange?.max || 0}`,
  524. `预算${criteria.budgetRange?.min || 0}-${criteria.budgetRange?.max || 0}`,
  525. ].filter(Boolean).join(' ');
  526. }
  527. function buildCreatorEmbeddingText(creator: JustOneCreator): string {
  528. return [
  529. creator.nickname,
  530. creator.contentType,
  531. ...(creator.contentTags || []),
  532. ...(creator.personalTags || []),
  533. ...(creator.contentSamples || []).flatMap((sample) => [sample.title, sample.content]),
  534. creator.city,
  535. creator.geoLocation,
  536. creator.gender,
  537. creator.cooperationMethod,
  538. `粉丝${creator.fansCount}`,
  539. `图文报价${creator.imagePrice}`,
  540. `视频报价${creator.videoPrice}`,
  541. ].filter(Boolean).join(' ');
  542. }
  543. function isMeaningfulKeyword(keyword: string): boolean {
  544. const normalized = keyword.trim();
  545. if (!normalized) return false;
  546. return !['生活方式', '垂直内容', '近期数据表现好', '真实自然', '非硬广', '图文', '视频'].includes(normalized);
  547. }
  548. function extractKeywordTerms(keyword: string): string[] {
  549. return keyword
  550. .replace(/类达人|达人|账号|粉丝号|垂类/g, '')
  551. .split(/[、,,\s/]+/)
  552. .map((item) => item.trim())
  553. .filter((item) => item.length >= 2);
  554. }
  555. function getCreatorProfileUrl(creator: JustOneCreator): string {
  556. if (creator.profileUrl) return creator.profileUrl;
  557. if (creator.xiaohongshuUrl) return creator.xiaohongshuUrl;
  558. if (creator.platform === 'xiaohongshu') return `https://www.xiaohongshu.com/user/profile/${creator.userId}`;
  559. return '';
  560. }
  561. function buildEmbedding(text: string): number[] {
  562. const vector = new Array(EMBEDDING_DIMENSION).fill(0);
  563. const normalized = text.toLowerCase().replace(/\s+/g, ' ').trim();
  564. const grams = new Set<string>();
  565. for (const token of normalized.split(/[ ,,、/|]+/).filter(Boolean)) {
  566. grams.add(token);
  567. for (let size = 2; size <= 4; size++) {
  568. for (let i = 0; i <= token.length - size; i++) {
  569. grams.add(token.slice(i, i + size));
  570. }
  571. }
  572. }
  573. for (const gram of grams) {
  574. const index = stableHash(gram) % EMBEDDING_DIMENSION;
  575. vector[index] += 1;
  576. }
  577. const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
  578. return norm > 0 ? vector.map((value) => Number((value / norm).toFixed(8))) : vector;
  579. }
  580. function normalizeCriteria(criteria: SearchCriteria): SearchCriteria {
  581. const maxFans = Number(criteria.fanRange?.max || 0);
  582. return {
  583. ...criteria,
  584. fanRange: maxFans > 0 && maxFans <= 1000
  585. ? { min: Number(criteria.fanRange?.min || 0) * 10000, max: maxFans * 10000 }
  586. : criteria.fanRange,
  587. };
  588. }
  589. function normalizeHeader(value: string): string {
  590. return String(value || '').replace(/\s+/g, '').replace(/&#10;/g, '').trim();
  591. }
  592. function normalizeRegions(region?: string | string[]): string[] {
  593. if (!region) return [];
  594. return (Array.isArray(region) ? region : String(region).split(/[、,,/]/))
  595. .map((item) => item.trim())
  596. .filter(Boolean);
  597. }
  598. function mapPlatform(platform: string): string {
  599. if (platform === '小红书') return 'xiaohongshu';
  600. if (platform === '抖音') return 'douyin';
  601. return platform;
  602. }
  603. function splitTags(value: string): string[] {
  604. return String(value || '').split(/[、,,/]/).map((item) => item.trim()).filter(Boolean);
  605. }
  606. function extractProfileId(url: string): string {
  607. return String(url || '').split('/').filter(Boolean).pop() || '';
  608. }
  609. function toNumber(value: unknown): number {
  610. if (typeof value === 'number') return value;
  611. const text = String(value || '').replace(/[¥,,]/g, '').trim();
  612. if (!text || text === '/') return 0;
  613. return Number(text) || 0;
  614. }
  615. function toCountFromWan(value: unknown): number {
  616. const num = toNumber(value);
  617. return num > 0 && num < 10000 ? Math.round(num * 10000) : Math.round(num);
  618. }
  619. function hashText(text: string): string {
  620. return stableHash(text).toString(36);
  621. }
  622. function stableHash(text: string): number {
  623. let hash = 2166136261;
  624. for (let i = 0; i < text.length; i++) {
  625. hash ^= text.charCodeAt(i);
  626. hash = Math.imul(hash, 16777619);
  627. }
  628. return Math.abs(hash >>> 0);
  629. }