server.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import { createServer } from 'node:http';
  2. import { config } from './config.ts';
  3. import { sendJson, readBody, extractPathParam, extractPathTail } from './utils/http.ts';
  4. import {
  5. handleUploadBrief,
  6. handleUploadCreatorLibrary,
  7. handleStartTask,
  8. handleGetTask,
  9. handleListTasks,
  10. handleExportTask,
  11. } from './routes/task.routes.ts';
  12. // ─── Legacy demo data (保留向下兼容) ──────────────────────────
  13. interface LegacyCandidate {
  14. platform: string;
  15. name: string;
  16. fans: string;
  17. price: number;
  18. score: number;
  19. styleMatch: number;
  20. status: string;
  21. reason: string;
  22. risk: string;
  23. }
  24. const demoRequirements = [
  25. { label: '客户/品牌', value: '客户A / 新锐护肤品牌', confidence: 96 },
  26. { label: '投放目标', value: '新品种草、真实测评、站内转化蓄水', confidence: 92 },
  27. { label: '平台需求', value: '小红书 20人、抖音 8人、B站 4人', confidence: 90 },
  28. { label: '内容风格', value: '真实体验、通勤生活、轻专业成分党', confidence: 88 },
  29. ];
  30. const demoRules = [
  31. '按 Brief 平台优先提号',
  32. '默认推荐数量不少于需求 3 倍',
  33. '风格匹配低于 80% 自动降级',
  34. '黑名单与低配合度剔除',
  35. '优先近期采集有效价格',
  36. ];
  37. const demoCandidates: LegacyCandidate[] = [
  38. {
  39. platform: '小红书',
  40. name: '梨涡测评室',
  41. fans: '18.6万',
  42. price: 6800,
  43. score: 94,
  44. styleMatch: 91,
  45. status: '强推荐',
  46. reason: '近10篇中 9 篇为真实体验或成分拆解,互动中位数高于同价位账号 28%。',
  47. risk: '视频内容偏少,可作为图文主推。',
  48. },
  49. {
  50. platform: '抖音',
  51. name: '林小林认真护肤',
  52. fans: '42.1万',
  53. price: 12800,
  54. score: 87,
  55. styleMatch: 82,
  56. status: '备选',
  57. reason: '爆文率和完播率表现稳定,预算接近上限,建议作为抖音头部备选。',
  58. risk: '单条报价较高,需确认档期。',
  59. },
  60. ];
  61. function escapeHtml(value: string): string {
  62. return value
  63. .replaceAll('&', '&')
  64. .replaceAll('<', '&lt;')
  65. .replaceAll('>', '&gt;')
  66. .replaceAll('"', '&quot;');
  67. }
  68. function sendExcel(response: import('node:http').ServerResponse, filename: string, candidates: LegacyCandidate[]): void {
  69. const rows = candidates
  70. .map(
  71. (candidate) => `
  72. <tr>
  73. <td>${escapeHtml(candidate.platform)}</td>
  74. <td>${escapeHtml(candidate.name)}</td>
  75. <td>${escapeHtml(candidate.fans)}</td>
  76. <td>${candidate.price}</td>
  77. <td>${candidate.score}</td>
  78. <td>${candidate.styleMatch}%</td>
  79. <td>${escapeHtml(candidate.status)}</td>
  80. <td>${escapeHtml(candidate.reason)}</td>
  81. <td>${escapeHtml(candidate.risk)}</td>
  82. </tr>`
  83. )
  84. .join('');
  85. const body = `
  86. <html>
  87. <head>
  88. <meta charset="UTF-8">
  89. <style>
  90. table { border-collapse: collapse; font-family: "Microsoft YaHei", Arial, sans-serif; }
  91. th { background: #1f8a70; color: #fff; }
  92. th, td { border: 1px solid #cfd7d1; padding: 8px 10px; }
  93. td:nth-child(5), td:nth-child(6) { background: #eef7f3; font-weight: 700; }
  94. </style>
  95. </head>
  96. <body>
  97. <table>
  98. <tr>
  99. <th>平台</th><th>账号</th><th>粉丝量</th><th>报价</th><th>综合分</th><th>风格匹配</th><th>推荐状态</th><th>推荐理由</th><th>风险提示</th>
  100. </tr>
  101. ${rows}
  102. </table>
  103. </body>
  104. </html>`;
  105. response.writeHead(200, {
  106. 'Access-Control-Allow-Origin': '*',
  107. 'Content-Type': 'application/vnd.ms-excel; charset=utf-8',
  108. 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
  109. 'Content-Length': Buffer.byteLength(body),
  110. });
  111. response.end(body);
  112. }
  113. // ─── HTTP Server ──────────────────────────────────────────────
  114. const server = createServer(async (request, response) => {
  115. const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
  116. const { pathname } = requestUrl;
  117. // CORS preflight
  118. if (request.method === 'OPTIONS') {
  119. sendJson(response, 200, { ok: true });
  120. return;
  121. }
  122. // ── 新接口:任务流水线 ────────────────────────────
  123. // POST /api/tasks/upload-brief - 上传 Brief
  124. if (request.method === 'POST' && pathname === '/api/tasks/upload-brief') {
  125. await handleUploadBrief(request, response);
  126. return;
  127. }
  128. // POST /api/creator-library/upload - 上传本地达人资料库
  129. if (request.method === 'POST' && pathname === '/api/creator-library/upload') {
  130. await handleUploadCreatorLibrary(request, response);
  131. return;
  132. }
  133. // GET /api/tasks - 任务列表
  134. if (request.method === 'GET' && pathname === '/api/tasks') {
  135. handleListTasks(response);
  136. return;
  137. }
  138. // GET /api/tasks/:taskId - 任务详情/进度
  139. if (request.method === 'GET' && pathname.startsWith('/api/tasks/')) {
  140. const taskId = extractPathParam(pathname, '/api/tasks/');
  141. if (taskId) {
  142. handleGetTask(response, taskId);
  143. return;
  144. }
  145. }
  146. // POST /api/tasks/:taskId/start - 启动任务
  147. if (request.method === 'POST' && pathname.match(/^\/api\/tasks\/[^/]+\/start$/)) {
  148. const taskId = extractPathParam(pathname, '/api/tasks/');
  149. handleStartTask(request, response, taskId);
  150. return;
  151. }
  152. // POST /api/tasks/:taskId/export - 导出推荐表
  153. if (request.method === 'POST' && pathname.match(/^\/api\/tasks\/[^/]+\/export$/)) {
  154. const tail = extractPathTail(pathname, '/api/tasks/');
  155. const taskId = tail.replace('/export', '');
  156. handleExportTask(response, taskId);
  157. return;
  158. }
  159. // ── 旧接口:保留向下兼容 ──────────────────────────
  160. if (request.method === 'GET' && pathname === '/api/health') {
  161. sendJson(response, 200, {
  162. ok: true,
  163. name: '提号AI API',
  164. resourcesPath: 'D:\\公司优秀媒体资源库\\4月份资源库',
  165. version: '2.0.0',
  166. features: ['task-pipeline', 'brief-upload', 'justone-api', 'tikhub-api', 'export'],
  167. });
  168. return;
  169. }
  170. if (request.method === 'POST' && pathname === '/api/analyze-brief') {
  171. sendJson(response, 200, {
  172. requirements: demoRequirements,
  173. rules: demoRules,
  174. message: 'Demo 已模拟解析 Brief,真实环境会接入 Excel/Docx 解析与 LLM 抽取。',
  175. });
  176. return;
  177. }
  178. if (request.method === 'POST' && pathname === '/api/recommend') {
  179. sendJson(response, 200, {
  180. candidates: demoCandidates,
  181. totalPool: 104,
  182. message: 'Demo 已模拟按规则生成推荐池。',
  183. });
  184. return;
  185. }
  186. if (request.method === 'POST' && pathname === '/api/rules/learn') {
  187. sendJson(response, 200, {
  188. suggestions: [
  189. { title: '小红书减少泛美妆号,优先生活方式和通勤场景', confidence: 93 },
  190. { title: '抖音预算压低时保留爆文率高账号', confidence: 87 },
  191. ],
  192. });
  193. return;
  194. }
  195. if (request.method === 'POST' && pathname === '/api/recommendations/export') {
  196. const body = await readBody(request);
  197. const candidates = Array.isArray(body['candidates'])
  198. ? (body['candidates'] as LegacyCandidate[])
  199. : demoCandidates;
  200. sendExcel(response, '客户A-新锐护肤品牌-需求推荐名单.xls', candidates);
  201. return;
  202. }
  203. sendJson(response, 404, { ok: false, message: 'Not found' });
  204. });
  205. server.listen(config.port, () => {
  206. console.log(`提号AI API listening on http://localhost:${config.port}`);
  207. console.log('新接口:');
  208. console.log(' POST /api/tasks/upload-brief - 上传 Brief 文件');
  209. console.log(' POST /api/tasks/:id/start - 启动任务处理');
  210. console.log(' GET /api/tasks/:id - 查询任务进度');
  211. console.log(' GET /api/tasks - 任务列表');
  212. console.log(' POST /api/tasks/:id/export - 导出推荐表');
  213. console.log('旧接口: /api/health, /api/analyze-brief, /api/recommend, /api/rules/learn, /api/recommendations/export');
  214. });