export.service.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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 evaluationHeaders = buildEvaluationHeaders(candidates);
  18. const colCount = 28 + evaluationHeaders.length;
  19. const headerRow = `
  20. <tr>
  21. <th>序号</th>
  22. <th>平台</th>
  23. <th>账号名称</th>
  24. <th>账号ID</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. <th>图文笔记报价(含平台服务费)</th>
  48. <th>视频笔记报价(含平台服务费)</th>
  49. ${evaluationHeaders.map((header) => `<th>${escapeHtml(header)}</th>`).join('')}
  50. </tr>`;
  51. const rows = candidates.map((c, index) => `
  52. <tr>
  53. <td>${index + 1}</td>
  54. <td>${escapeHtml(mapPlatformDisplay(c.platform))}</td>
  55. <td>${escapeHtml(c.displayName)}</td>
  56. <td>${escapeHtml(c.platformUserId)}</td>
  57. <td>${escapeHtml(c.location || '-')}</td>
  58. <td>${escapeHtml(c.profileUrl)}</td>
  59. <td>${formatFans(c.fansCount)}</td>
  60. <td>${c.imagePrice > 0 ? `¥${c.imagePrice}` : '-'}</td>
  61. <td>${c.videoPrice > 0 ? `¥${c.videoPrice}` : '-'}</td>
  62. <td>${c.minPrice > 0 ? `¥${c.minPrice}` : '-'}</td>
  63. <td>${escapeHtml(c.contentTags.join('、') || '-')}</td>
  64. <td>${c.score}</td>
  65. <td>${c.styleMatch}%</td>
  66. <td>${escapeHtml(c.recommendStatus)}</td>
  67. <td>${escapeHtml(c.recommendReason || '-')}</td>
  68. <td>${escapeHtml(c.riskNote || '-')}</td>
  69. <td>${escapeHtml(c.sourceProvider)}</td>
  70. <td>${escapeHtml(c.displayName)}</td>
  71. <td>${escapeHtml(c.gender || '-')}</td>
  72. <td>${formatWan(c.fansCount)}</td>
  73. <td>${formatWan(c.likedCollectCount || 0)}</td>
  74. <td>${escapeHtml(c.contentType || c.contentTags.join('、') || '-')}</td>
  75. <td>${escapeHtml(c.city || c.location || '-')}</td>
  76. <td>${escapeHtml(c.geoLocation || c.location || '-')}</td>
  77. <td>${escapeHtml(c.xiaohongshuUrl || (c.platform === 'xiaohongshu' ? c.profileUrl : '-'))}</td>
  78. <td>${escapeHtml(c.cooperationMethod || '-')}</td>
  79. <td>${c.imagePrice > 0 ? `¥${c.imagePrice}` : '-'}</td>
  80. <td>${c.videoPrice > 0 ? `¥${c.videoPrice}` : '-'}</td>
  81. ${buildEvaluationCells(c, evaluationHeaders)}
  82. </tr>`
  83. ).join('');
  84. const html = `
  85. <html xmlns:o="urn:schemas-microsoft-com:office:office"
  86. xmlns:x="urn:schemas-microsoft-com:office:excel"
  87. xmlns="http://www.w3.org/TR/REC-html40">
  88. <head>
  89. <meta charset="UTF-8">
  90. <meta name="ProgId" content="Excel.Sheet">
  91. <!--[if gte mso 9]>
  92. <xml>
  93. <x:ExcelWorkbook>
  94. <x:ExcelWorksheets>
  95. <x:ExcelWorksheet>
  96. <x:Name>推荐名单</x:Name>
  97. <x:WorksheetOptions>
  98. <x:DisplayGridlines/>
  99. </x:WorksheetOptions>
  100. </x:ExcelWorksheet>
  101. </x:ExcelWorksheets>
  102. </x:ExcelWorkbook>
  103. </xml>
  104. <![endif]-->
  105. <style>
  106. table { border-collapse: collapse; font-family: "Microsoft YaHei", Arial, sans-serif; font-size: 11pt; }
  107. th { background: #1f8a70; color: #fff; font-weight: bold; text-align: center; }
  108. th, td { border: 1px solid #cfd7d1; padding: 6px 10px; white-space: nowrap; }
  109. .title-row td { font-size: 14pt; font-weight: bold; border: none; padding: 10px 0; }
  110. .meta-row td { font-size: 9pt; color: #666; border: none; padding: 2px 0; }
  111. td:nth-child(12), td:nth-child(13) { background: #eef7f3; font-weight: 700; }
  112. </style>
  113. </head>
  114. <body>
  115. <table>
  116. <tr class="title-row"><td colspan="${colCount}">${escapeHtml(title)} - 媒体资源推荐表</td></tr>
  117. <tr class="meta-row"><td colspan="${colCount}">生成时间:${now} | 候选总数:${candidates.length} | 强推荐:${candidates.filter(c => c.recommendStatus === '强推荐').length} | 备选:${candidates.filter(c => c.recommendStatus === '备选').length}</td></tr>
  118. <tr><td colspan="${colCount}"></td></tr>
  119. ${headerRow}
  120. ${rows}
  121. </table>
  122. </body>
  123. </html>`;
  124. return Buffer.from(html, 'utf-8');
  125. }
  126. function buildEvaluationHeaders(candidates: NormalizedCandidate[]): string[] {
  127. const headers: string[] = [];
  128. const has = (predicate: (candidate: NormalizedCandidate) => boolean) => candidates.some(predicate);
  129. if (has((candidate) => Boolean(candidate.evaluation?.cpmCpe))) headers.push('CPM/CPE判断', '图文CPM', '视频CPM', '图文CPE', '视频CPE');
  130. if (has((candidate) => Boolean(candidate.evaluation?.commercialStability))) headers.push('商单稳定性判断', '商单数', '近30天商单数', '商单阅读中位数', '商单互动中位数');
  131. if (has((candidate) => Boolean(candidate.evaluation?.recentPerformance))) headers.push('近期数据判断', '近样本互动中位数', '近期/前期互动比', '互动波动倍数');
  132. if (has((candidate) => Boolean(candidate.evaluation?.updateFrequency))) headers.push('更新频率判断', '近30天样本数');
  133. if (has((candidate) => Boolean(candidate.evaluation?.commentQuality))) headers.push('评论区判断', '评论中位数');
  134. if (has((candidate) => Boolean(candidate.evaluation?.publicSentiment))) headers.push('舆情风险词', '舆情判断');
  135. if (has((candidate) => Boolean(candidate.evaluation?.audienceGender))) headers.push('女性粉丝占比', '粉丝性别判断');
  136. if (has((candidate) => Boolean(candidate.evaluation?.notes?.length))) headers.push('按需评估摘要');
  137. return headers;
  138. }
  139. function buildEvaluationCells(candidate: NormalizedCandidate, headers: string[]): string {
  140. const evaluation = candidate.evaluation;
  141. return headers.map((header) => {
  142. const value = evaluationCellValue(candidate, header, evaluation);
  143. return `<td>${escapeHtml(value)}</td>`;
  144. }).join('');
  145. }
  146. function evaluationCellValue(
  147. _candidate: NormalizedCandidate,
  148. header: string,
  149. evaluation?: NormalizedCandidate['evaluation'],
  150. ): string {
  151. if (!evaluation) return '-';
  152. const cpm = evaluation.cpmCpe;
  153. const commercial = evaluation.commercialStability;
  154. const recent = evaluation.recentPerformance;
  155. const update = evaluation.updateFrequency;
  156. const comment = evaluation.commentQuality;
  157. const sentiment = evaluation.publicSentiment;
  158. const audience = evaluation.audienceGender;
  159. const values: Record<string, string> = {
  160. 'CPM/CPE判断': cpm?.verdict || '-',
  161. '图文CPM': formatOptionalNumber(cpm?.pictureCpm),
  162. '视频CPM': formatOptionalNumber(cpm?.videoCpm),
  163. '图文CPE': formatOptionalNumber(cpm?.pictureCpe),
  164. '视频CPE': formatOptionalNumber(cpm?.videoCpe),
  165. '商单稳定性判断': commercial?.verdict || '-',
  166. '商单数': formatOptionalNumber(commercial?.businessNoteCount),
  167. '近30天商单数': formatOptionalNumber(commercial?.coopNoteNum30d),
  168. '商单阅读中位数': formatOptionalNumber(commercial?.readMidCoop30),
  169. '商单互动中位数': formatOptionalNumber(commercial?.interMidCoop30),
  170. '近期数据判断': recent?.verdict || '-',
  171. '近样本互动中位数': formatOptionalNumber(recent?.recentMedianInteraction || recent?.medianInteraction),
  172. '近期/前期互动比': formatOptionalNumber(recent?.trendRatio),
  173. '互动波动倍数': formatOptionalNumber(recent?.volatility),
  174. '更新频率判断': update?.verdict || '-',
  175. '近30天样本数': formatOptionalNumber(update?.recent30DayNotes),
  176. '评论区判断': comment?.verdict || '-',
  177. '评论中位数': formatOptionalNumber(comment?.commentMedian),
  178. '舆情风险词': sentiment?.riskKeywordHits?.join('、') || '-',
  179. '舆情判断': sentiment?.verdict || '-',
  180. '女性粉丝占比': audience?.femaleRatio !== undefined ? `${Math.round(audience.femaleRatio * 100)}%` : '-',
  181. '粉丝性别判断': audience?.verdict || '-',
  182. '按需评估摘要': evaluation.notes?.join(';') || '-',
  183. };
  184. return values[header] || '-';
  185. }
  186. function formatOptionalNumber(value?: number): string {
  187. return value === undefined || value === null || Number.isNaN(value) ? '-' : String(value);
  188. }
  189. function mapPlatformDisplay(platform: string): string {
  190. const map: Record<string, string> = {
  191. xiaohongshu: '小红书',
  192. douyin: '抖音',
  193. bilibili: 'B站',
  194. weibo: '微博',
  195. weixin: '微信/公众号',
  196. };
  197. return map[platform] || platform;
  198. }
  199. function formatFans(count: number): string {
  200. if (count === 0) return '-';
  201. if (count >= 10000) return `${(count / 10000).toFixed(1)}万`;
  202. return String(count);
  203. }
  204. function formatWan(count: number): string {
  205. if (!count) return '-';
  206. const value = count >= 10000 ? count / 10000 : count;
  207. return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
  208. }