local-creator-db.service.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  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. return 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. }
  72. export async function archiveProviderResponse(params: {
  73. provider: string;
  74. endpoint: string;
  75. requestParams: Record<string, unknown>;
  76. responseBody: unknown;
  77. }): Promise<void> {
  78. const fetchedAt = new Date();
  79. const expiresAt = new Date(fetchedAt.getTime() + RAW_CACHE_TTL_MS);
  80. const safeEndpoint = params.endpoint.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 80);
  81. const archiveDir = `${PROVIDER_ARCHIVE_DIR}/${fetchedAt.toISOString().slice(0, 7)}`;
  82. await mkdir(archiveDir, { recursive: true });
  83. const id = `${params.provider}_${fetchedAt.getTime()}_${Math.random().toString(36).slice(2, 8)}`;
  84. const archivePath = `${archiveDir}/${id}_${safeEndpoint}.json`;
  85. await writeFile(archivePath, JSON.stringify(params, null, 2), 'utf-8');
  86. await pool.query(
  87. `INSERT INTO provider_raw_cache
  88. (provider, endpoint, request_params, response_body, fetched_at, expires_at, normalized_status, archive_path)
  89. VALUES ($1, $2, $3::jsonb, $4::jsonb, $5, $6, 'pending', $7)`,
  90. [
  91. params.provider,
  92. params.endpoint,
  93. JSON.stringify(params.requestParams),
  94. JSON.stringify(params.responseBody),
  95. fetchedAt,
  96. expiresAt,
  97. archivePath,
  98. ],
  99. );
  100. }
  101. export async function recordRetrievalEvent(params: {
  102. taskId?: string;
  103. queryText: string;
  104. criteria: unknown;
  105. localHitCount: number;
  106. providerHitCount: number;
  107. finalCount: number;
  108. }): Promise<void> {
  109. await pool.query(
  110. `INSERT INTO creator_retrieval_event
  111. (task_id, query_text, criteria, local_hit_count, provider_hit_count, final_count)
  112. VALUES ($1, $2, $3::jsonb, $4, $5, $6)`,
  113. [
  114. params.taskId || null,
  115. params.queryText,
  116. JSON.stringify(params.criteria || {}),
  117. params.localHitCount,
  118. params.providerHitCount,
  119. params.finalCount,
  120. ],
  121. );
  122. }
  123. async function upsertCleanCreators(creators: ParsedCreator[]): Promise<void> {
  124. const client = await pool.connect();
  125. try {
  126. await client.query('BEGIN');
  127. for (const creator of creators) {
  128. const embeddingText = creator.embeddingText || buildCreatorEmbeddingText(creator);
  129. await client.query(
  130. `INSERT INTO creator_profile_clean (
  131. platform, platform_user_id, display_name, gender, fans_count, liked_collect_count,
  132. content_type, content_tags, persona_tags, city, geo_location, profile_url,
  133. cooperation_method, image_price, video_price, min_price, cooperation_status,
  134. source_kind, source_provider, source_file, source_confidence, updated_at,
  135. embedding_text, embedding
  136. ) VALUES (
  137. $1, $2, $3, $4, $5, $6,
  138. $7, $8::jsonb, $9::jsonb, $10, $11, $12,
  139. $13, $14, $15, $16, $17,
  140. $18, $19, $20, $21, now(),
  141. $22, $23::double precision[]
  142. )
  143. ON CONFLICT (platform, platform_user_id) DO UPDATE SET
  144. display_name = EXCLUDED.display_name,
  145. gender = EXCLUDED.gender,
  146. fans_count = EXCLUDED.fans_count,
  147. liked_collect_count = EXCLUDED.liked_collect_count,
  148. content_type = EXCLUDED.content_type,
  149. content_tags = EXCLUDED.content_tags,
  150. persona_tags = EXCLUDED.persona_tags,
  151. city = EXCLUDED.city,
  152. geo_location = EXCLUDED.geo_location,
  153. profile_url = EXCLUDED.profile_url,
  154. cooperation_method = EXCLUDED.cooperation_method,
  155. image_price = EXCLUDED.image_price,
  156. video_price = EXCLUDED.video_price,
  157. min_price = EXCLUDED.min_price,
  158. cooperation_status = EXCLUDED.cooperation_status,
  159. source_kind = EXCLUDED.source_kind,
  160. source_provider = EXCLUDED.source_provider,
  161. source_file = EXCLUDED.source_file,
  162. source_confidence = EXCLUDED.source_confidence,
  163. updated_at = now(),
  164. embedding_text = EXCLUDED.embedding_text,
  165. embedding = EXCLUDED.embedding`,
  166. [
  167. creator.platform,
  168. creator.userId,
  169. creator.nickname,
  170. creator.gender || null,
  171. creator.fansCount || 0,
  172. creator.likedCollectCount || 0,
  173. creator.contentType || creator.contentTags.join('、'),
  174. JSON.stringify(creator.contentTags || []),
  175. JSON.stringify(creator.personalTags || []),
  176. creator.city || creator.location || null,
  177. creator.geoLocation || creator.location || null,
  178. creator.xiaohongshuUrl || null,
  179. creator.cooperationMethod || null,
  180. creator.imagePrice || 0,
  181. creator.videoPrice || 0,
  182. creator.minPrice || 0,
  183. creator.cooperationStatus || null,
  184. creator.sourceKind,
  185. creator.sourceProvider || 'local-upload',
  186. creator.sourceFile || null,
  187. creator.sourceConfidence || 90,
  188. embeddingText,
  189. buildEmbedding(embeddingText),
  190. ],
  191. );
  192. }
  193. await client.query('COMMIT');
  194. } catch (error) {
  195. await client.query('ROLLBACK');
  196. throw error;
  197. } finally {
  198. client.release();
  199. }
  200. }
  201. function parseCreatorRows(rows: string[][], sourceFile: string): ParsedCreator[] {
  202. const headerIndex = rows.findIndex((row) => row.some((cell) => normalizeHeader(cell) === '昵称'));
  203. if (headerIndex < 0) return [];
  204. const headers = rows[headerIndex].map(normalizeHeader);
  205. const getIndex = (...names: string[]) => headers.findIndex((header) => names.includes(header));
  206. const indexMap = {
  207. nickname: getIndex('昵称', '账号名称'),
  208. gender: getIndex('性别'),
  209. fansWan: getIndex('粉丝数(万)', '粉丝数'),
  210. likedWan: getIndex('赞藏数(万)', '赞藏数'),
  211. contentType: getIndex('内容类型'),
  212. city: getIndex('城市'),
  213. geoLocation: getIndex('地理位置'),
  214. profileUrl: getIndex('小红书主页', '主页链接'),
  215. cooperationMethod: getIndex('合作方式'),
  216. imagePrice: headers.findIndex((header) => header.includes('图文') && header.includes('报价')),
  217. videoPrice: headers.findIndex((header) => header.includes('视频') && header.includes('报价')),
  218. };
  219. if (indexMap.nickname < 0 || indexMap.profileUrl < 0) return [];
  220. return rows.slice(headerIndex + 1)
  221. .filter((row) => row[indexMap.nickname])
  222. .map((row) => {
  223. const nickname = row[indexMap.nickname] || '';
  224. const profileUrl = row[indexMap.profileUrl] || '';
  225. const userId = extractProfileId(profileUrl) || hashText(`${sourceFile}:${nickname}:${profileUrl}`);
  226. const contentType = row[indexMap.contentType] || '';
  227. const imagePrice = toNumber(row[indexMap.imagePrice]);
  228. const videoPrice = toNumber(row[indexMap.videoPrice]);
  229. const fansCount = toCountFromWan(row[indexMap.fansWan]);
  230. const likedCollectCount = toCountFromWan(row[indexMap.likedWan]);
  231. const city = row[indexMap.city] || '';
  232. const geoLocation = row[indexMap.geoLocation] || '';
  233. const contentTags = splitTags(contentType);
  234. const creator: ParsedCreator = {
  235. userId,
  236. platform: 'xiaohongshu',
  237. nickname,
  238. redId: userId,
  239. location: city || geoLocation,
  240. fansCount,
  241. imagePrice,
  242. videoPrice,
  243. minPrice: Math.min(...[imagePrice, videoPrice].filter((price) => price > 0)) || 0,
  244. cooperationStatus: row[indexMap.cooperationMethod] ? 'active' : '',
  245. personalTags: [],
  246. contentTags,
  247. sourceProvider: 'local-upload',
  248. sourceKind: 'uploaded',
  249. sourceFile,
  250. gender: row[indexMap.gender] || '',
  251. likedCollectCount,
  252. contentType,
  253. city,
  254. geoLocation,
  255. xiaohongshuUrl: profileUrl,
  256. cooperationMethod: row[indexMap.cooperationMethod] || '',
  257. embeddingText: '',
  258. };
  259. creator.embeddingText = buildCreatorEmbeddingText(creator);
  260. return creator;
  261. });
  262. }
  263. function rowToCreator(row: Record<string, unknown>): JustOneCreator {
  264. return {
  265. userId: String(row.platform_user_id || ''),
  266. platform: String(row.platform || ''),
  267. nickname: String(row.display_name || ''),
  268. redId: String(row.platform_user_id || ''),
  269. location: String(row.city || row.geo_location || ''),
  270. fansCount: Number(row.fans_count || 0),
  271. imagePrice: Number(row.image_price || 0),
  272. videoPrice: Number(row.video_price || 0),
  273. minPrice: Number(row.min_price || 0),
  274. cooperationStatus: String(row.cooperation_status || ''),
  275. personalTags: Array.isArray(row.persona_tags) ? row.persona_tags as string[] : [],
  276. contentTags: Array.isArray(row.content_tags) ? row.content_tags as string[] : [],
  277. sourceProvider: String(row.source_provider || 'local-db'),
  278. gender: String(row.gender || ''),
  279. likedCollectCount: Number(row.liked_collect_count || 0),
  280. contentType: String(row.content_type || ''),
  281. city: String(row.city || ''),
  282. geoLocation: String(row.geo_location || ''),
  283. xiaohongshuUrl: String(row.profile_url || ''),
  284. cooperationMethod: String(row.cooperation_method || ''),
  285. };
  286. }
  287. function scoreRetrievedCreator(row: Record<string, unknown>, criteria: SearchCriteria): number {
  288. let score = Number(row.similarity || 0) * 70;
  289. const text = String(row.embedding_text || '').toLowerCase();
  290. const keywords = [...(criteria.contentTags || []), ...(criteria.keywords || [])].map((item) => item.toLowerCase());
  291. const regions = normalizeRegions(criteria.region);
  292. for (const keyword of keywords) {
  293. if (keyword && text.includes(keyword)) score += 12;
  294. }
  295. for (const region of regions) {
  296. if (region && text.includes(region.toLowerCase())) score += 10;
  297. }
  298. for (const exclude of criteria.excludeTags || []) {
  299. if (exclude && text.includes(exclude.toLowerCase())) score -= 4;
  300. }
  301. if (String(row.cooperation_method || '').includes('视频')) score += 8;
  302. if (Number(row.video_price || 0) <= (criteria.budgetRange?.max || Infinity)) score += 6;
  303. return score;
  304. }
  305. function buildCriteriaEmbeddingText(criteria: SearchCriteria): string {
  306. return [
  307. ...(criteria.keywords || []),
  308. ...(criteria.contentTags || []),
  309. ...normalizeRegions(criteria.region),
  310. criteria.gender || '',
  311. `粉丝${criteria.fanRange?.min || 0}-${criteria.fanRange?.max || 0}`,
  312. `预算${criteria.budgetRange?.min || 0}-${criteria.budgetRange?.max || 0}`,
  313. ].filter(Boolean).join(' ');
  314. }
  315. function buildCreatorEmbeddingText(creator: JustOneCreator): string {
  316. return [
  317. creator.nickname,
  318. creator.contentType,
  319. ...(creator.contentTags || []),
  320. ...(creator.personalTags || []),
  321. creator.city,
  322. creator.geoLocation,
  323. creator.gender,
  324. creator.cooperationMethod,
  325. `粉丝${creator.fansCount}`,
  326. `图文报价${creator.imagePrice}`,
  327. `视频报价${creator.videoPrice}`,
  328. ].filter(Boolean).join(' ');
  329. }
  330. function buildEmbedding(text: string): number[] {
  331. const vector = new Array(EMBEDDING_DIMENSION).fill(0);
  332. const normalized = text.toLowerCase().replace(/\s+/g, ' ').trim();
  333. const grams = new Set<string>();
  334. for (const token of normalized.split(/[ ,,、/|]+/).filter(Boolean)) {
  335. grams.add(token);
  336. for (let size = 2; size <= 4; size++) {
  337. for (let i = 0; i <= token.length - size; i++) {
  338. grams.add(token.slice(i, i + size));
  339. }
  340. }
  341. }
  342. for (const gram of grams) {
  343. const index = stableHash(gram) % EMBEDDING_DIMENSION;
  344. vector[index] += 1;
  345. }
  346. const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
  347. return norm > 0 ? vector.map((value) => Number((value / norm).toFixed(8))) : vector;
  348. }
  349. function normalizeCriteria(criteria: SearchCriteria): SearchCriteria {
  350. const maxFans = Number(criteria.fanRange?.max || 0);
  351. return {
  352. ...criteria,
  353. fanRange: maxFans > 0 && maxFans <= 1000
  354. ? { min: Number(criteria.fanRange?.min || 0) * 10000, max: maxFans * 10000 }
  355. : criteria.fanRange,
  356. };
  357. }
  358. function normalizeHeader(value: string): string {
  359. return String(value || '').replace(/\s+/g, '').replace(/&#10;/g, '').trim();
  360. }
  361. function normalizeRegions(region?: string | string[]): string[] {
  362. if (!region) return [];
  363. return (Array.isArray(region) ? region : String(region).split(/[、,,/]/))
  364. .map((item) => item.trim())
  365. .filter(Boolean);
  366. }
  367. function mapPlatform(platform: string): string {
  368. if (platform === '小红书') return 'xiaohongshu';
  369. if (platform === '抖音') return 'douyin';
  370. return platform;
  371. }
  372. function splitTags(value: string): string[] {
  373. return String(value || '').split(/[、,,/]/).map((item) => item.trim()).filter(Boolean);
  374. }
  375. function extractProfileId(url: string): string {
  376. return String(url || '').split('/').filter(Boolean).pop() || '';
  377. }
  378. function toNumber(value: unknown): number {
  379. if (typeof value === 'number') return value;
  380. const text = String(value || '').replace(/[¥,,]/g, '').trim();
  381. if (!text || text === '/') return 0;
  382. return Number(text) || 0;
  383. }
  384. function toCountFromWan(value: unknown): number {
  385. const num = toNumber(value);
  386. return num > 0 && num < 10000 ? Math.round(num * 10000) : Math.round(num);
  387. }
  388. function hashText(text: string): string {
  389. return stableHash(text).toString(36);
  390. }
  391. function stableHash(text: string): number {
  392. let hash = 2166136261;
  393. for (let i = 0; i < text.length; i++) {
  394. hash ^= text.charCodeAt(i);
  395. hash = Math.imul(hash, 16777619);
  396. }
  397. return Math.abs(hash >>> 0);
  398. }