export.service.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import type { NormalizedCandidate } from './recommendation.service.ts';
  2. function escapeHtml(value: string): string {
  3. return value
  4. .replaceAll('&', '&')
  5. .replaceAll('<', '&lt;')
  6. .replaceAll('>', '&gt;')
  7. .replaceAll('"', '&quot;');
  8. }
  9. /**
  10. * 生成媒体资源推荐表 Excel (HTML table format compatible with MS Excel)
  11. * 按模板生成:账号名称、ID、账号类型、地区、链接、粉丝数、赞藏数、
  12. * 图文/视频报价、推荐等级、推荐理由等字段
  13. */
  14. export function generateExcelBuffer(candidates: NormalizedCandidate[], taskFileName?: string): Buffer {
  15. const title = taskFileName ? taskFileName.replace(/\.[^.]+$/, '') : '达人推荐表';
  16. const now = new Date().toLocaleDateString('zh-CN');
  17. const headerRow = `
  18. <tr>
  19. <th>序号</th>
  20. <th>平台</th>
  21. <th>账号名称</th>
  22. <th>账号ID</th>
  23. <th>地区</th>
  24. <th>主页链接</th>
  25. <th>粉丝数</th>
  26. <th>图文报价</th>
  27. <th>视频报价</th>
  28. <th>最低报价</th>
  29. <th>内容标签</th>
  30. <th>综合评分</th>
  31. <th>风格匹配</th>
  32. <th>推荐等级</th>
  33. <th>推荐理由</th>
  34. <th>风险提示</th>
  35. <th>数据来源</th>
  36. <th>昵称</th>
  37. <th>性别</th>
  38. <th>粉丝数(万)</th>
  39. <th>赞藏数(万)</th>
  40. <th>内容类型</th>
  41. <th>城市</th>
  42. <th>地理位置</th>
  43. <th>小红书主页</th>
  44. <th>合作方式</th>
  45. <th>图文笔记报价(含平台服务费)</th>
  46. <th>视频笔记报价(含平台服务费)</th>
  47. </tr>`;
  48. const rows = candidates.map((c, index) => `
  49. <tr>
  50. <td>${index + 1}</td>
  51. <td>${escapeHtml(mapPlatformDisplay(c.platform))}</td>
  52. <td>${escapeHtml(c.displayName)}</td>
  53. <td>${escapeHtml(c.platformUserId)}</td>
  54. <td>${escapeHtml(c.location || '-')}</td>
  55. <td>${escapeHtml(c.profileUrl)}</td>
  56. <td>${formatFans(c.fansCount)}</td>
  57. <td>${c.imagePrice > 0 ? `¥${c.imagePrice}` : '-'}</td>
  58. <td>${c.videoPrice > 0 ? `¥${c.videoPrice}` : '-'}</td>
  59. <td>${c.minPrice > 0 ? `¥${c.minPrice}` : '-'}</td>
  60. <td>${escapeHtml(c.contentTags.join('、') || '-')}</td>
  61. <td>${c.score}</td>
  62. <td>${c.styleMatch}%</td>
  63. <td>${escapeHtml(c.recommendStatus)}</td>
  64. <td>${escapeHtml(c.recommendReason || '-')}</td>
  65. <td>${escapeHtml(c.riskNote || '-')}</td>
  66. <td>${escapeHtml(c.sourceProvider)}</td>
  67. <td>${escapeHtml(c.displayName)}</td>
  68. <td>${escapeHtml(c.gender || '-')}</td>
  69. <td>${formatWan(c.fansCount)}</td>
  70. <td>${formatWan(c.likedCollectCount || 0)}</td>
  71. <td>${escapeHtml(c.contentType || c.contentTags.join('、') || '-')}</td>
  72. <td>${escapeHtml(c.city || c.location || '-')}</td>
  73. <td>${escapeHtml(c.geoLocation || c.location || '-')}</td>
  74. <td>${escapeHtml(c.xiaohongshuUrl || (c.platform === 'xiaohongshu' ? c.profileUrl : '-'))}</td>
  75. <td>${escapeHtml(c.cooperationMethod || '-')}</td>
  76. <td>${c.imagePrice > 0 ? `¥${c.imagePrice}` : '-'}</td>
  77. <td>${c.videoPrice > 0 ? `¥${c.videoPrice}` : '-'}</td>
  78. </tr>`
  79. ).join('');
  80. const html = `
  81. <html xmlns:o="urn:schemas-microsoft-com:office:office"
  82. xmlns:x="urn:schemas-microsoft-com:office:excel"
  83. xmlns="http://www.w3.org/TR/REC-html40">
  84. <head>
  85. <meta charset="UTF-8">
  86. <meta name="ProgId" content="Excel.Sheet">
  87. <!--[if gte mso 9]>
  88. <xml>
  89. <x:ExcelWorkbook>
  90. <x:ExcelWorksheets>
  91. <x:ExcelWorksheet>
  92. <x:Name>推荐名单</x:Name>
  93. <x:WorksheetOptions>
  94. <x:DisplayGridlines/>
  95. </x:WorksheetOptions>
  96. </x:ExcelWorksheet>
  97. </x:ExcelWorksheets>
  98. </x:ExcelWorkbook>
  99. </xml>
  100. <![endif]-->
  101. <style>
  102. table { border-collapse: collapse; font-family: "Microsoft YaHei", Arial, sans-serif; font-size: 11pt; }
  103. th { background: #1f8a70; color: #fff; font-weight: bold; text-align: center; }
  104. th, td { border: 1px solid #cfd7d1; padding: 6px 10px; white-space: nowrap; }
  105. .title-row td { font-size: 14pt; font-weight: bold; border: none; padding: 10px 0; }
  106. .meta-row td { font-size: 9pt; color: #666; border: none; padding: 2px 0; }
  107. td:nth-child(12), td:nth-child(13) { background: #eef7f3; font-weight: 700; }
  108. </style>
  109. </head>
  110. <body>
  111. <table>
  112. <tr class="title-row"><td colspan="28">${escapeHtml(title)} - 媒体资源推荐表</td></tr>
  113. <tr class="meta-row"><td colspan="28">生成时间:${now} | 候选总数:${candidates.length} | 强推荐:${candidates.filter(c => c.recommendStatus === '强推荐').length} | 备选:${candidates.filter(c => c.recommendStatus === '备选').length}</td></tr>
  114. <tr><td colspan="28"></td></tr>
  115. ${headerRow}
  116. ${rows}
  117. </table>
  118. </body>
  119. </html>`;
  120. return Buffer.from(html, 'utf-8');
  121. }
  122. function mapPlatformDisplay(platform: string): string {
  123. const map: Record<string, string> = {
  124. xiaohongshu: '小红书',
  125. douyin: '抖音',
  126. bilibili: 'B站',
  127. weibo: '微博',
  128. weixin: '微信/公众号',
  129. };
  130. return map[platform] || platform;
  131. }
  132. function formatFans(count: number): string {
  133. if (count === 0) return '-';
  134. if (count >= 10000) return `${(count / 10000).toFixed(1)}万`;
  135. return String(count);
  136. }
  137. function formatWan(count: number): string {
  138. if (!count) return '-';
  139. const value = count >= 10000 ? count / 10000 : count;
  140. return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
  141. }