homepage-evidence-provider-smoke.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const http = require('http');
  4. const os = require('os');
  5. const path = require('path');
  6. const { runTihaoSourcing } = require('../mcp/src/tools/tihao-brief-sourcing-run');
  7. const { SOFTWARE_TABLE_HEADER } = require('../mcp/src/features/tihao-sourcing/report');
  8. async function main() {
  9. const out = path.join(os.tmpdir(), `tihao-homepage-provider-smoke-${Date.now()}`);
  10. const mock = await startMockProvider({ mode: 'ok' });
  11. try {
  12. const result = await runHomepageSmoke({
  13. mock,
  14. output: out,
  15. token: 'mock-homepage-token'
  16. });
  17. assert(result.status === 'ok', 'homepage provider smoke should return ok');
  18. assert(result.data.criteria.homepageEvidenceStatus.status === 'ok', 'homepage provider status should be ok');
  19. assert(result.data.criteria.homepageEvidenceStatus.evidenceCount >= 2, 'homepage provider should report evidence count');
  20. assert(result.data.candidates.some(item => item.homepageEvidence?.source === 'provider'), 'at least one client candidate should use provider homepage evidence');
  21. await assertProviderVisualRiskDowngrade();
  22. assertMockHomepageContract(mock);
  23. const csvPath = path.join(out, 'tihao-sourcing-client-list.csv');
  24. const csv = fs.readFileSync(csvPath, 'utf8');
  25. assert(csv.replace(/^\uFEFF/, '').startsWith(SOFTWARE_TABLE_HEADER.join(',')), 'csv should use software table header');
  26. assert(!/sk-[A-Za-z0-9_-]{20,}|Authorization:\s*Bearer\s+[A-Za-z0-9._-]{8,}/i.test(JSON.stringify(result) + csv), 'outputs should not leak provider token');
  27. await assertUnauthorizedFallback();
  28. console.log(`homepage evidence provider smoke ok: ${out}`);
  29. } finally {
  30. await mock.close();
  31. }
  32. }
  33. async function runHomepageSmoke({ mock, output, token }) {
  34. return runTihaoSourcing({
  35. collectionMode: 'sample',
  36. briefText: [
  37. '客户名称:花漾肌研',
  38. '平台与人数:小红书 6 人,抖音 2 人',
  39. '粉丝量范围:2万-50万',
  40. '预算范围:3000-18000',
  41. '偏好真实体验、素人感、成分拆解、敏感肌修护,不要硬广。'
  42. ].join('\n'),
  43. homepageEvidenceBaseUrl: mock.url,
  44. homepageEvidenceToken: token,
  45. homepageEvidenceLimit: 10,
  46. homepageEvidenceCreatorsLimit: 8,
  47. output
  48. });
  49. }
  50. function startMockProvider({ mode }) {
  51. const state = { requests: [] };
  52. const server = http.createServer((req, res) => {
  53. if (!req.url.startsWith('/homepage-evidence')) {
  54. res.writeHead(404, { 'content-type': 'application/json' });
  55. res.end(JSON.stringify({ error: 'not_found' }));
  56. return;
  57. }
  58. let body = '';
  59. req.on('data', chunk => { body += chunk; });
  60. req.on('end', () => {
  61. const payload = body ? JSON.parse(body) : {};
  62. state.requests.push({
  63. method: req.method,
  64. url: req.url,
  65. headers: req.headers,
  66. body: payload
  67. });
  68. if (mode === 'unauthorized') {
  69. res.writeHead(401, { 'content-type': 'application/json' });
  70. res.end(JSON.stringify({ error: 'hidden by client', detail: 'quota exceeded' }));
  71. return;
  72. }
  73. const creators = payload.creators || [];
  74. const records = creators.slice(0, 3).map((creator, index) => ({
  75. platform: creator.platform,
  76. platformUserId: creator.platformUserId,
  77. displayName: creator.displayName,
  78. profileUrl: creator.profileUrl,
  79. homepageEvidence: {
  80. source: 'provider',
  81. recentContentWindow: 10,
  82. categoryConsistencyScore: 86 - index,
  83. visualQualityScore: 82 - index,
  84. toneConsistencyScore: 88 - index,
  85. confidence: 'provider',
  86. matchedTerms: ['真实体验', '敏感肌', '成分拆解'],
  87. riskHits: [],
  88. reviewNotes: ['provider返回近10篇内容证据', '封面统一且近期内容垂类一致']
  89. },
  90. recentPosts: [
  91. { title: '敏感肌屏障修护真实体验' },
  92. { title: '通勤护肤空瓶记录' }
  93. ]
  94. }));
  95. res.writeHead(200, { 'content-type': 'application/json' });
  96. res.end(JSON.stringify({ homepageEvidence: records }));
  97. });
  98. });
  99. return new Promise(resolve => {
  100. server.listen(0, '127.0.0.1', () => {
  101. const port = server.address().port;
  102. resolve({
  103. url: `http://127.0.0.1:${port}`,
  104. state,
  105. close: () => new Promise(done => server.close(done))
  106. });
  107. });
  108. });
  109. }
  110. function assertMockHomepageContract(mock) {
  111. assert(mock.state.requests.length >= 1, 'homepage provider should receive a request');
  112. const request = mock.state.requests[0];
  113. assert(request.method === 'POST', 'homepage provider should receive POST');
  114. assert(request.headers.authorization === 'Bearer mock-homepage-token', 'homepage provider should receive Authorization header when token is supplied');
  115. assert(request.body.contractVersion === 'tihao-homepage-evidence-v1', 'homepage request should include contractVersion');
  116. assert(Array.isArray(request.body.requestedCapabilities), 'homepage request should include requestedCapabilities');
  117. for (const capability of ['recent_posts', 'cover_quality', 'category_consistency', 'tone_consistency', 'risk_review']) {
  118. assert(request.body.requestedCapabilities.includes(capability), `homepage request should include ${capability}`);
  119. }
  120. assert(request.body.limit === 10, 'homepage request should include requested limit');
  121. assert(Array.isArray(request.body.creators) && request.body.creators.length > 0, 'homepage request should include creators');
  122. assert(request.body.creators.length <= 8, 'homepage request should respect creators limit');
  123. assert(request.body.creators.every(item => item.displayName && item.profileUrl), 'homepage request creators should include displayName and profileUrl');
  124. }
  125. async function assertUnauthorizedFallback() {
  126. const out = path.join(os.tmpdir(), `tihao-homepage-provider-failure-${Date.now()}`);
  127. const mock = await startMockProvider({ mode: 'unauthorized' });
  128. try {
  129. const result = await runHomepageSmoke({
  130. mock,
  131. output: out,
  132. token: 'mock-homepage-token'
  133. });
  134. assert(result.status === 'ok', 'homepage unauthorized fallback should still return ok');
  135. assert(result.data.criteria.homepageEvidenceStatus.status === 'unauthorized_or_quota', 'homepage unauthorized fallback should keep structured status');
  136. assert(result.data.candidates.some(item => item.homepageEvidence?.source !== 'provider'), 'homepage unauthorized fallback should use local evidence');
  137. assertPublicOutputSafe({ result, out });
  138. } finally {
  139. await mock.close();
  140. }
  141. }
  142. async function assertProviderVisualRiskDowngrade() {
  143. const out = path.join(os.tmpdir(), `tihao-homepage-provider-risk-${Date.now()}`);
  144. const records = [{
  145. platform: 'xiaohongshu',
  146. platformUserId: 'xhs-skincare-001',
  147. homepageEvidence: {
  148. source: 'provider',
  149. recentContentWindow: 10,
  150. categoryConsistencyScore: 92,
  151. visualQualityScore: 30,
  152. toneConsistencyScore: 90,
  153. confidence: 'provider',
  154. matchedTerms: ['敏感肌', '真实体验', '成分拆解'],
  155. visualRiskSignals: ['封面下沉', '封面混乱'],
  156. riskHits: [],
  157. reviewNotes: ['provider返回近10篇内容证据', '主页质感风险:封面下沉、封面混乱']
  158. }
  159. }];
  160. const fixture = path.join(out, 'homepage-risk.json');
  161. fs.mkdirSync(out, { recursive: true });
  162. fs.writeFileSync(fixture, JSON.stringify({ homepageEvidence: records }, null, 2), 'utf8');
  163. const result = await runTihaoSourcing({
  164. collectionMode: 'sample',
  165. briefText: [
  166. '客户名称:花漾肌研',
  167. '平台:小红书',
  168. '粉丝量范围:2万-50万',
  169. '预算范围:3000-18000',
  170. '关键词:敏感肌、屏障修护、成分拆解、真实体验',
  171. '偏好真实体验、素人感、成分拆解、敏感肌修护。'
  172. ].join('\n'),
  173. homepageEvidenceFile: fixture,
  174. output: path.join(out, 'report')
  175. });
  176. const risky = result.data.candidates.find(item => item.platformUserId === 'xhs-skincare-001');
  177. assert(risky, 'provider visual-risk candidate should exist');
  178. assert(risky.recommendStatus !== '强推荐', 'provider visual-risk candidate should not be strong recommendation');
  179. assert((risky.homepageQualityRisks || []).includes('封面下沉'), 'provider visual-risk candidate should keep visual risk signals');
  180. assert(String(risky.riskNote || '').includes('主页质感风险'), 'provider visual-risk candidate should explain homepage quality risk');
  181. }
  182. function assertPublicOutputSafe({ result, out }) {
  183. const markdownPath = path.join(out, 'tihao-sourcing-report.md');
  184. const csvPath = path.join(out, 'tihao-sourcing-client-list.csv');
  185. const publicText = [
  186. result.assistantMessage || '',
  187. fs.existsSync(markdownPath) ? fs.readFileSync(markdownPath, 'utf8') : '',
  188. fs.existsSync(csvPath) ? fs.readFileSync(csvPath, 'utf8') : ''
  189. ].join('\n');
  190. assert(!/\b(401|403)\b/.test(publicText), 'homepage provider public output should not expose raw 401/403');
  191. assert(!/hidden by client|unauthorized|permission|quota exceeded/i.test(publicText), 'homepage provider public output should not expose raw upstream error text');
  192. assert(!/Authorization\s*:/i.test(publicText), 'homepage provider public output should not expose Authorization header');
  193. assert(!/Bearer\s+[A-Za-z0-9._~+/=-]+/i.test(publicText), 'homepage provider public output should not expose bearer token');
  194. assert(!/sk-[A-Za-z0-9_-]{20,}/.test(publicText), 'homepage provider public output should not expose model token');
  195. }
  196. function assert(condition, message) {
  197. if (!condition) throw new Error(message);
  198. }
  199. main().catch(error => {
  200. console.error(error && error.stack ? error.stack : String(error));
  201. process.exit(1);
  202. });