389 行
16 KiB
TypeScript
389 行
16 KiB
TypeScript
import { CardSeoService } from '../../../src/modules/card/service/seo';
|
|
import { CardPortalService } from '../../../src/modules/card/service/portal';
|
|
import {
|
|
OpenCardPortalController,
|
|
PortalRequestContext,
|
|
} from '../../../src/modules/card/controller/open/portal';
|
|
import { CardPortalDetail } from '../../../src/comm/types/omni';
|
|
|
|
describe('Point 29-B: 社交爬虫探测与微官网短链服务端渲染集成 (Social Crawler & SEO SSR)', () => {
|
|
let cardSeoService: CardSeoService;
|
|
let cardPortalService: CardPortalService;
|
|
let portalController: OpenCardPortalController;
|
|
|
|
// 模拟真实名片聚合资产
|
|
const mockAlexCardDetail: CardPortalDetail = {
|
|
id: 101,
|
|
slug: 'alex-zheng',
|
|
name: 'Alex Zheng',
|
|
title: 'VP of Global Expansion',
|
|
company: 'OmniCard Global Inc.',
|
|
slogan: 'Empowering Global Business Connections',
|
|
avatarUrl: 'https://cdn.omnicard.ai/avatars/alex.png',
|
|
phone: '+86-13800138000',
|
|
email: 'alex@omnicard.ai',
|
|
whatsapp: '+8613800138000',
|
|
linkedin: 'https://linkedin.com/in/alex-zheng',
|
|
timezone: 'Asia/Shanghai',
|
|
audioPronunciationUrl: 'https://cdn.omnicard.ai/audio/alex.mp3',
|
|
themeCode: 'corporate_navy',
|
|
status: 'published',
|
|
bentoBlocks: [
|
|
{
|
|
id: 1,
|
|
cardId: 101,
|
|
type: 'hero',
|
|
colSpan: 2,
|
|
rowSpan: 1,
|
|
sortOrder: 1,
|
|
title: 'About Alex',
|
|
content: '10+ years in international business expansion.',
|
|
},
|
|
],
|
|
cachedAt: Date.now(),
|
|
};
|
|
|
|
// 常用社交爬虫与普通用户 UA 常量
|
|
const USER_AGENTS = {
|
|
whatsapp:
|
|
'WhatsApp/2.21.12.21 A',
|
|
whatsappIos:
|
|
'WhatsApp/2.23.20.0 i',
|
|
linkedin:
|
|
'LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +http://www.linkedin.com)',
|
|
twitter:
|
|
'Twitterbot/1.0',
|
|
facebook:
|
|
'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)',
|
|
facebot:
|
|
'Facebot',
|
|
telegram:
|
|
'TelegramBot (like TwitterBot)',
|
|
slack:
|
|
'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)',
|
|
discord:
|
|
'Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)',
|
|
googlebot:
|
|
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
|
safariMobile:
|
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
|
|
chromeDesktop:
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
cardSeoService = new CardSeoService();
|
|
cardPortalService = new CardPortalService();
|
|
|
|
interface CustomHttpError extends Error {
|
|
status?: number;
|
|
}
|
|
|
|
// Mock cardPortalService.getPortalDetail
|
|
jest
|
|
.spyOn(cardPortalService, 'getPortalDetail')
|
|
.mockImplementation(async (slugOrId: string | number) => {
|
|
if (slugOrId === 'alex-zheng' || slugOrId === 101 || slugOrId === '101') {
|
|
return { ...mockAlexCardDetail };
|
|
}
|
|
const error = new Error('名片不存在或已下线') as CustomHttpError;
|
|
error.status = 404;
|
|
throw error;
|
|
});
|
|
|
|
portalController = new OpenCardPortalController();
|
|
portalController.cardPortalService = cardPortalService;
|
|
portalController.cardSeoService = cardSeoService;
|
|
});
|
|
|
|
describe('1. 社交爬虫与机器人 User-Agent 精准探测识别', () => {
|
|
it('应准确识别 WhatsApp 爬虫 User-Agent', () => {
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.whatsapp)).toBe(true);
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.whatsappIos)).toBe(true);
|
|
expect(cardSeoService.detectCrawler(USER_AGENTS.whatsapp)).toBe('whatsapp');
|
|
});
|
|
|
|
it('应准确识别 LinkedInBot 爬虫 User-Agent', () => {
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.linkedin)).toBe(true);
|
|
expect(cardSeoService.detectCrawler(USER_AGENTS.linkedin)).toBe('linkedin');
|
|
});
|
|
|
|
it('应准确识别 Twitterbot 爬虫 User-Agent', () => {
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.twitter)).toBe(true);
|
|
expect(cardSeoService.detectCrawler(USER_AGENTS.twitter)).toBe('twitter');
|
|
});
|
|
|
|
it('应准确识别 facebookexternalhit 与 Facebot 爬虫 User-Agent', () => {
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.facebook)).toBe(true);
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.facebot)).toBe(true);
|
|
expect(cardSeoService.detectCrawler(USER_AGENTS.facebook)).toBe('facebook');
|
|
});
|
|
|
|
it('应准确识别 TelegramBot 爬虫 User-Agent', () => {
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.telegram)).toBe(true);
|
|
expect(cardSeoService.detectCrawler(USER_AGENTS.telegram)).toBe('telegram');
|
|
});
|
|
|
|
it('应支持识别 Slackbot、Discordbot 及主流搜索引擎机器人', () => {
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.slack)).toBe(true);
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.discord)).toBe(true);
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.googlebot)).toBe(true);
|
|
});
|
|
|
|
it('常规移动端与桌面端浏览器不应被判定为爬虫', () => {
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.safariMobile)).toBe(false);
|
|
expect(cardSeoService.isCrawler(USER_AGENTS.chromeDesktop)).toBe(false);
|
|
expect(cardSeoService.detectCrawler(USER_AGENTS.chromeDesktop)).toBeNull();
|
|
});
|
|
|
|
it('对于空字符串或 undefined User-Agent 应安全降级返回 false', () => {
|
|
expect(cardSeoService.isCrawler('')).toBe(false);
|
|
expect(cardSeoService.isCrawler(undefined)).toBe(false);
|
|
expect(cardSeoService.detectCrawler('')).toBeNull();
|
|
expect(cardSeoService.detectCrawler(undefined)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('2. OpenGraph 元数据提炼与微官网 HTML 模板服务端渲染', () => {
|
|
it('应正确从名片提取 og:title、og:image、og:description 等元数据', () => {
|
|
const ogMeta = cardSeoService.generateOgMeta(mockAlexCardDetail);
|
|
|
|
expect(ogMeta.title).toBe('Alex Zheng - VP of Global Expansion | OmniCard Global Inc.');
|
|
expect(ogMeta.image).toBe('https://cdn.omnicard.ai/avatars/alex.png');
|
|
expect(ogMeta.description).toBe('Empowering Global Business Connections');
|
|
expect(ogMeta.type).toBe('profile');
|
|
});
|
|
|
|
it('渲染输出的 HTML 必须包含完整的 OpenGraph、Twitter Cards 与 JSON-LD 标签', () => {
|
|
const html = cardSeoService.renderCardHtml(mockAlexCardDetail);
|
|
|
|
// HTML5 规范结构
|
|
expect(html).toContain('<!DOCTYPE html>');
|
|
expect(html).toContain('<html');
|
|
expect(html).toContain('<head>');
|
|
expect(html).toContain('</head>');
|
|
expect(html).toContain('<body>');
|
|
expect(html).toContain('</body>');
|
|
|
|
// OpenGraph 协议标准断言
|
|
expect(html).toContain('<meta property="og:title" content="Alex Zheng - VP of Global Expansion | OmniCard Global Inc."');
|
|
expect(html).toContain('<meta property="og:image" content="https://cdn.omnicard.ai/avatars/alex.png"');
|
|
expect(html).toContain('<meta property="og:description" content="Empowering Global Business Connections"');
|
|
expect(html).toContain('<meta property="og:type" content="profile"');
|
|
expect(html).toContain('<meta property="og:url" content="https://omnicard.ai/p/alex-zheng"');
|
|
|
|
// Twitter Cards 断言
|
|
expect(html).toContain('<meta name="twitter:card" content="summary_large_image"');
|
|
expect(html).toContain('<meta name="twitter:title" content="Alex Zheng - VP of Global Expansion | OmniCard Global Inc."');
|
|
expect(html).toContain('<meta name="twitter:image" content="https://cdn.omnicard.ai/avatars/alex.png"');
|
|
|
|
// JSON-LD 结构化数据断言
|
|
expect(html).toContain('<script type="application/ld+json">');
|
|
expect(html).toContain('"@type":"Person"');
|
|
expect(html).toContain('"name":"Alex Zheng"');
|
|
expect(html).toContain('"jobTitle":"VP of Global Expansion"');
|
|
|
|
// 包含名片主要信息
|
|
expect(html).toContain('Alex Zheng');
|
|
expect(html).toContain('VP of Global Expansion');
|
|
expect(html).toContain('OmniCard Global Inc.');
|
|
});
|
|
|
|
it('处理特殊字符时必须严格 HTML 实体转义以防范 XSS 注入', () => {
|
|
const maliciousCard: CardPortalDetail = {
|
|
...mockAlexCardDetail,
|
|
name: 'Evil <script>alert("xss")</script>',
|
|
company: 'Hack & Co "Global"',
|
|
slogan: 'Test <img src=x onerror=alert(1)> & more',
|
|
};
|
|
|
|
const html = cardSeoService.renderCardHtml(maliciousCard);
|
|
|
|
expect(html).not.toContain('<script>alert("xss")</script>');
|
|
expect(html).toContain('Evil <script>alert("xss")</script>');
|
|
expect(html).toContain('Hack & Co "Global"');
|
|
});
|
|
|
|
it('当名片推荐语言为阿拉伯语等 RTL 语言时,服务端首屏 HTML 必须注入 dir="rtl" 属性', () => {
|
|
const arabicCard: CardPortalDetail = {
|
|
...mockAlexCardDetail,
|
|
suggestedLocale: 'ar',
|
|
};
|
|
const html = cardSeoService.renderCardHtml(arabicCard);
|
|
expect(html).toContain('<html lang="ar" dir="rtl">');
|
|
|
|
const uaeCard: CardPortalDetail = {
|
|
...mockAlexCardDetail,
|
|
suggestedLocale: 'ar-AE',
|
|
};
|
|
const uaeHtml = cardSeoService.renderCardHtml(uaeCard);
|
|
expect(uaeHtml).toContain('<html lang="ar-AE" dir="rtl">');
|
|
});
|
|
|
|
it('生成 404 兜底 HTML 时应提示名片已下线或不存在', () => {
|
|
const notFoundHtml = cardSeoService.renderNotFoundHtml('名片不存在或已下线');
|
|
|
|
expect(notFoundHtml).toContain('<!DOCTYPE html>');
|
|
expect(notFoundHtml).toContain('名片不存在或已下线');
|
|
expect(notFoundHtml).toContain('<meta property="og:title" content="名片未找到 | OmniCard AI"');
|
|
});
|
|
});
|
|
|
|
describe('3. 控制器公开路由 GET /open/card/portal/preview-html/:slug 集成断言', () => {
|
|
const createMockCtx = (userAgent?: string): PortalRequestContext => {
|
|
const headerMap: Record<string, string> = {};
|
|
if (userAgent) {
|
|
headerMap['user-agent'] = userAgent;
|
|
}
|
|
return {
|
|
headers: headerMap,
|
|
set: jest.fn((k: string, v: string) => {
|
|
headerMap[k.toLowerCase()] = v;
|
|
}),
|
|
get: jest.fn((field: string) => headerMap[field.toLowerCase()]),
|
|
status: 200,
|
|
body: undefined,
|
|
};
|
|
};
|
|
|
|
it('模拟 WhatsApp 爬虫访问 preview-html 路由,返回 200、text/html 及包含 og:title 和 og:image', async () => {
|
|
const mockCtx = createMockCtx(USER_AGENTS.whatsapp);
|
|
portalController.ctx = mockCtx;
|
|
|
|
const html = await portalController.previewHtml('alex-zheng');
|
|
|
|
expect(typeof html).toBe('string');
|
|
expect(mockCtx.status).toBe(200);
|
|
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
|
|
expect(html).toContain('og:title');
|
|
expect(html).toContain('Alex Zheng - VP of Global Expansion | OmniCard Global Inc.');
|
|
expect(html).toContain('og:image');
|
|
expect(html).toContain('https://cdn.omnicard.ai/avatars/alex.png');
|
|
});
|
|
|
|
it('模拟 LinkedInBot 爬虫访问,断言返回 200 与 OpenGraph HTML', async () => {
|
|
const mockCtx = createMockCtx(USER_AGENTS.linkedin);
|
|
portalController.ctx = mockCtx;
|
|
|
|
const html = await portalController.previewHtml('alex-zheng');
|
|
|
|
expect(mockCtx.status).toBe(200);
|
|
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
|
|
expect(html).toContain('og:title');
|
|
expect(html).toContain('og:image');
|
|
expect(html).toContain('Alex Zheng');
|
|
});
|
|
|
|
it('模拟 Twitterbot 爬虫访问,断言返回 200 与 twitter:card 元数据', async () => {
|
|
const mockCtx = createMockCtx(USER_AGENTS.twitter);
|
|
portalController.ctx = mockCtx;
|
|
|
|
const html = await portalController.previewHtml('alex-zheng');
|
|
|
|
expect(mockCtx.status).toBe(200);
|
|
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
|
|
expect(html).toContain('twitter:card');
|
|
expect(html).toContain('twitter:image');
|
|
expect(html).toContain('https://cdn.omnicard.ai/avatars/alex.png');
|
|
});
|
|
|
|
it('模拟 facebookexternalhit 爬虫访问,断言返回 200', async () => {
|
|
const mockCtx = createMockCtx(USER_AGENTS.facebook);
|
|
portalController.ctx = mockCtx;
|
|
|
|
const html = await portalController.previewHtml('alex-zheng');
|
|
|
|
expect(mockCtx.status).toBe(200);
|
|
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
|
|
expect(html).toContain('og:title');
|
|
});
|
|
|
|
it('模拟 TelegramBot 爬虫访问,断言返回 200', async () => {
|
|
const mockCtx = createMockCtx(USER_AGENTS.telegram);
|
|
portalController.ctx = mockCtx;
|
|
|
|
const html = await portalController.previewHtml('alex-zheng');
|
|
|
|
expect(mockCtx.status).toBe(200);
|
|
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
|
|
expect(html).toContain('og:title');
|
|
expect(html).toContain('og:image');
|
|
});
|
|
|
|
it('普通桌面浏览器在 preview-html 路由下也能正常获取真实名片渲染预览', async () => {
|
|
const mockCtx = createMockCtx(USER_AGENTS.chromeDesktop);
|
|
portalController.ctx = mockCtx;
|
|
|
|
const html = await portalController.previewHtml('alex-zheng');
|
|
|
|
expect(mockCtx.status).toBe(200);
|
|
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
|
|
expect(html).toContain('Alex Zheng');
|
|
expect(html).toContain('OmniCard Global Inc.');
|
|
});
|
|
|
|
it('访问不存在的名片 slug 时应返回 404 状态码及兜底 HTML', async () => {
|
|
const mockCtx = createMockCtx(USER_AGENTS.whatsapp);
|
|
portalController.ctx = mockCtx;
|
|
|
|
const html = await portalController.previewHtml('non-existent-slug');
|
|
|
|
expect(mockCtx.status).toBe(404);
|
|
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
|
|
expect(html).toContain('名片不存在或已下线');
|
|
});
|
|
});
|
|
|
|
describe('4. 短链路由 GET /open/card/portal/:slugOrId 智能爬虫探测集成', () => {
|
|
it('当社交爬虫请求普通短链路由时,自动响应 SEO 预览 HTML 而非 JSON', async () => {
|
|
const headerMap: Record<string, string> = {
|
|
'user-agent': USER_AGENTS.whatsapp,
|
|
};
|
|
const mockCtx: PortalRequestContext = {
|
|
headers: headerMap,
|
|
set: jest.fn((k: string, v: string) => {
|
|
headerMap[k.toLowerCase()] = v;
|
|
}),
|
|
get: jest.fn((field: string) => headerMap[field.toLowerCase()]),
|
|
status: 200,
|
|
};
|
|
portalController.ctx = mockCtx;
|
|
|
|
const res = await portalController.getBySlugOrId('alex-zheng');
|
|
|
|
// 确认为 HTML 字符串响应,并设置了 Content-Type: text/html
|
|
expect(typeof res).toBe('string');
|
|
expect(res).toContain('og:title');
|
|
expect(res).toContain('Alex Zheng');
|
|
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
|
|
expect(mockCtx.status).toBe(200);
|
|
});
|
|
|
|
it('普通移动端用户或 API 客户端访问短链路由时,保持标准 JSON API 响应不变', async () => {
|
|
const headerMap: Record<string, string> = {
|
|
'user-agent': USER_AGENTS.safariMobile,
|
|
};
|
|
const mockCtx: PortalRequestContext = {
|
|
headers: headerMap,
|
|
set: jest.fn(),
|
|
get: jest.fn((field: string) => headerMap[field.toLowerCase()]),
|
|
status: 200,
|
|
};
|
|
portalController.ctx = mockCtx;
|
|
|
|
const res = await portalController.getBySlugOrId('alex-zheng');
|
|
|
|
interface ApiOkResponse {
|
|
code: number;
|
|
message: string;
|
|
data: CardPortalDetail;
|
|
}
|
|
|
|
expect(typeof res).toBe('object');
|
|
const okRes = res as ApiOkResponse;
|
|
expect(okRes.code).toBe(1000);
|
|
expect(okRes.data.slug).toBe('alex-zheng');
|
|
});
|
|
});
|
|
});
|