export.service.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  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. </tr>`;
  37. const rows = candidates.map((c, index) => `
  38. <tr>
  39. <td>${index + 1}</td>
  40. <td>${escapeHtml(mapPlatformDisplay(c.platform))}</td>
  41. <td>${escapeHtml(c.displayName)}</td>
  42. <td>${escapeHtml(c.platformUserId)}</td>
  43. <td>${escapeHtml(c.location || '-')}</td>
  44. <td>${escapeHtml(c.profileUrl)}</td>
  45. <td>${formatFans(c.fansCount)}</td>
  46. <td>${c.imagePrice > 0 ? `¥${c.imagePrice}` : '-'}</td>
  47. <td>${c.videoPrice > 0 ? `¥${c.videoPrice}` : '-'}</td>
  48. <td>${c.minPrice > 0 ? `¥${c.minPrice}` : '-'}</td>
  49. <td>${escapeHtml(c.contentTags.join('、') || '-')}</td>
  50. <td>${c.score}</td>
  51. <td>${c.styleMatch}%</td>
  52. <td>${escapeHtml(c.recommendStatus)}</td>
  53. <td>${escapeHtml(c.recommendReason || '-')}</td>
  54. <td>${escapeHtml(c.riskNote || '-')}</td>
  55. <td>${escapeHtml(c.sourceProvider)}</td>
  56. </tr>`
  57. ).join('');
  58. const html = `
  59. <html xmlns:o="urn:schemas-microsoft-com:office:office"
  60. xmlns:x="urn:schemas-microsoft-com:office:excel"
  61. xmlns="http://www.w3.org/TR/REC-html40">
  62. <head>
  63. <meta charset="UTF-8">
  64. <meta name="ProgId" content="Excel.Sheet">
  65. <!--[if gte mso 9]>
  66. <xml>
  67. <x:ExcelWorkbook>
  68. <x:ExcelWorksheets>
  69. <x:ExcelWorksheet>
  70. <x:Name>推荐名单</x:Name>
  71. <x:WorksheetOptions>
  72. <x:DisplayGridlines/>
  73. </x:WorksheetOptions>
  74. </x:ExcelWorksheet>
  75. </x:ExcelWorksheets>
  76. </x:ExcelWorkbook>
  77. </xml>
  78. <![endif]-->
  79. <style>
  80. table { border-collapse: collapse; font-family: "Microsoft YaHei", Arial, sans-serif; font-size: 11pt; }
  81. th { background: #1f8a70; color: #fff; font-weight: bold; text-align: center; }
  82. th, td { border: 1px solid #cfd7d1; padding: 6px 10px; white-space: nowrap; }
  83. .title-row td { font-size: 14pt; font-weight: bold; border: none; padding: 10px 0; }
  84. .meta-row td { font-size: 9pt; color: #666; border: none; padding: 2px 0; }
  85. td:nth-child(12), td:nth-child(13) { background: #eef7f3; font-weight: 700; }
  86. </style>
  87. </head>
  88. <body>
  89. <table>
  90. <tr class="title-row"><td colspan="17">${escapeHtml(title)} - 媒体资源推荐表</td></tr>
  91. <tr class="meta-row"><td colspan="17">生成时间:${now} | 候选总数:${candidates.length} | 强推荐:${candidates.filter(c => c.recommendStatus === '强推荐').length} | 备选:${candidates.filter(c => c.recommendStatus === '备选').length}</td></tr>
  92. <tr><td colspan="17"></td></tr>
  93. ${headerRow}
  94. ${rows}
  95. </table>
  96. </body>
  97. </html>`;
  98. return Buffer.from(html, 'utf-8');
  99. }
  100. function mapPlatformDisplay(platform: string): string {
  101. const map: Record<string, string> = {
  102. xiaohongshu: '小红书',
  103. douyin: '抖音',
  104. bilibili: 'B站',
  105. weibo: '微博',
  106. weixin: '微信/公众号',
  107. };
  108. return map[platform] || platform;
  109. }
  110. function formatFans(count: number): string {
  111. if (count === 0) return '-';
  112. if (count >= 10000) return `${(count / 10000).toFixed(1)}万`;
  113. return String(count);
  114. }