Skip to content

Commit 062e1cf

Browse files
authored
feat(seo): add qualified users sitemap (#3761)
1 parent df93bcb commit 062e1cf

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

__tests__/sitemaps.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,12 +331,223 @@ describe('GET /sitemaps/index.xml', () => {
331331
expect(res.text).toContain(
332332
'<loc>http://localhost:5002/api/sitemaps/squads.xml</loc>',
333333
);
334+
expect(res.text).toContain(
335+
'<loc>http://localhost:5002/api/sitemaps/users.xml</loc>',
336+
);
334337
expect(res.text).toContain(
335338
'<loc>http://localhost:5002/api/sitemaps/tags.xml</loc>',
336339
);
337340
});
338341
});
339342

343+
describe('GET /sitemaps/users.xml', () => {
344+
it('should include only qualified author profiles', async () => {
345+
const updatedAt = new Date('2024-01-01T12:00:00.123Z');
346+
const userBase = {
347+
createdAt: now,
348+
infoConfirmed: true,
349+
reputation: 20,
350+
};
351+
const publicPostBase = {
352+
sourceId: 'a',
353+
createdAt: now,
354+
type: PostType.Article,
355+
visible: true,
356+
private: false,
357+
deleted: false,
358+
};
359+
360+
await con.getRepository(User).save([
361+
{
362+
...userBase,
363+
id: 'qualified-user',
364+
name: 'Qualified User',
365+
image: 'https://daily.dev/qualified.jpg',
366+
username: 'qualifieduser',
367+
email: 'qualified@test.com',
368+
updatedAt,
369+
reputation: 42,
370+
bio: 'Writes public posts',
371+
},
372+
{
373+
...userBase,
374+
id: 'low-rep-user',
375+
name: 'Low Rep User',
376+
image: 'https://daily.dev/low-rep.jpg',
377+
username: 'lowrepuser',
378+
email: 'lowrep@test.com',
379+
reputation: 10,
380+
bio: 'Below threshold',
381+
},
382+
{
383+
...userBase,
384+
id: 'empty-bio-user',
385+
name: 'Empty Bio User',
386+
image: 'https://daily.dev/empty-bio.jpg',
387+
username: 'emptybio',
388+
email: 'emptybio@test.com',
389+
bio: '',
390+
},
391+
{
392+
...userBase,
393+
id: 'null-bio-user',
394+
name: 'Null Bio User',
395+
image: 'https://daily.dev/null-bio.jpg',
396+
username: 'nullbio',
397+
email: 'nullbio@test.com',
398+
bio: null,
399+
},
400+
{
401+
...userBase,
402+
id: 'blank-bio-user',
403+
name: 'Blank Bio User',
404+
image: 'https://daily.dev/blank-bio.jpg',
405+
username: 'blankbio',
406+
email: 'blankbio@test.com',
407+
bio: ' ',
408+
},
409+
{
410+
...userBase,
411+
id: 'missing-username-user',
412+
name: 'Missing Username User',
413+
image: 'https://daily.dev/no-username.jpg',
414+
email: 'nousername@test.com',
415+
bio: 'Has no username',
416+
},
417+
{
418+
...userBase,
419+
id: 'private-post-user',
420+
name: 'Private Post User',
421+
image: 'https://daily.dev/private-post.jpg',
422+
username: 'privatepost',
423+
email: 'privatepost@test.com',
424+
bio: 'Only private posts',
425+
},
426+
{
427+
...userBase,
428+
id: 'deleted-post-user',
429+
name: 'Deleted Post User',
430+
image: 'https://daily.dev/deleted-post.jpg',
431+
username: 'deletedpost',
432+
email: 'deletedpost@test.com',
433+
bio: 'Only deleted posts',
434+
},
435+
{
436+
...userBase,
437+
id: 'hidden-post-user',
438+
name: 'Hidden Post User',
439+
image: 'https://daily.dev/hidden-post.jpg',
440+
username: 'hiddenpost',
441+
email: 'hiddenpost@test.com',
442+
bio: 'Only hidden posts',
443+
},
444+
{
445+
...userBase,
446+
id: 'no-posts-user',
447+
name: 'No Posts User',
448+
image: 'https://daily.dev/no-posts.jpg',
449+
username: 'noposts',
450+
email: 'noposts@test.com',
451+
bio: 'Has no posts',
452+
},
453+
]);
454+
455+
await con.getRepository(Post).insert([
456+
{
457+
...publicPostBase,
458+
id: 'qualified-user-post',
459+
shortId: 'qup',
460+
title: 'Qualified User Post',
461+
metadataChangedAt: updatedAt,
462+
authorId: 'qualified-user',
463+
},
464+
{
465+
...publicPostBase,
466+
id: 'low-rep-post',
467+
shortId: 'lrp',
468+
title: 'Low Rep Post',
469+
authorId: 'low-rep-user',
470+
},
471+
{
472+
...publicPostBase,
473+
id: 'empty-bio-post',
474+
shortId: 'ebp',
475+
title: 'Empty Bio Post',
476+
authorId: 'empty-bio-user',
477+
},
478+
{
479+
...publicPostBase,
480+
id: 'null-bio-post',
481+
shortId: 'nbp',
482+
title: 'Null Bio Post',
483+
authorId: 'null-bio-user',
484+
},
485+
{
486+
...publicPostBase,
487+
id: 'blank-bio-post',
488+
shortId: 'bbp',
489+
title: 'Blank Bio Post',
490+
authorId: 'blank-bio-user',
491+
},
492+
{
493+
...publicPostBase,
494+
id: 'missing-username-post',
495+
shortId: 'mup',
496+
title: 'Missing Username Post',
497+
authorId: 'missing-username-user',
498+
},
499+
{
500+
...publicPostBase,
501+
id: 'private-post-only',
502+
shortId: 'ppo',
503+
title: 'Private Post Only',
504+
authorId: 'private-post-user',
505+
private: true,
506+
},
507+
{
508+
...publicPostBase,
509+
id: 'deleted-post-only',
510+
shortId: 'dpo',
511+
title: 'Deleted Post Only',
512+
authorId: 'deleted-post-user',
513+
deleted: true,
514+
},
515+
{
516+
...publicPostBase,
517+
id: 'hidden-post-only',
518+
shortId: 'hpo',
519+
title: 'Hidden Post Only',
520+
authorId: 'hidden-post-user',
521+
visible: false,
522+
},
523+
]);
524+
525+
const res = await request(app.server)
526+
.get('/sitemaps/users.xml')
527+
.expect(200);
528+
529+
expect(res.header['content-type']).toContain('application/xml');
530+
expect(res.header['cache-control']).toEqual(
531+
'public, max-age=7200, s-maxage=7200',
532+
);
533+
expect(res.text).toContain(
534+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
535+
);
536+
expect(res.text).toContain(
537+
'<loc>http://localhost:5002/qualifieduser</loc>',
538+
);
539+
expect(res.text).toContain('<lastmod>2024-01-01T12:00:00.123Z</lastmod>');
540+
expect(res.text).not.toContain('/lowrepuser');
541+
expect(res.text).not.toContain('/emptybio');
542+
expect(res.text).not.toContain('/nullbio');
543+
expect(res.text).not.toContain('/blankbio');
544+
expect(res.text).not.toContain('/privatepost');
545+
expect(res.text).not.toContain('/deletedpost');
546+
expect(res.text).not.toContain('/hiddenpost');
547+
expect(res.text).not.toContain('/noposts');
548+
});
549+
});
550+
340551
describe('GET /sitemaps/agents.xml', () => {
341552
it('should return arena entity pages sitemap as xml', async () => {
342553
const res = await request(app.server)

src/routes/sitemaps.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
User,
1111
} from '../entity';
1212
import { AGENTS_DIGEST_SOURCE } from '../entity/Source';
13+
import { getUserProfileUrl } from '../common/users';
1314
import createOrGetConnection from '../db';
1415
import { Readable } from 'stream';
1516
import { ONE_HOUR_IN_SECONDS } from '../common/constants';
@@ -301,6 +302,35 @@ const buildSquadsSitemapQuery = (
301302
.orderBy('s."createdAt"', 'DESC')
302303
.limit(DEFAULT_SITEMAP_LIMIT);
303304

305+
const buildUsersSitemapQuery = (
306+
source: DataSource | EntityManager,
307+
): SelectQueryBuilder<User> =>
308+
source
309+
.createQueryBuilder()
310+
.select('u.username', 'username')
311+
.addSelect('u."updatedAt"', 'lastmod')
312+
.from(User, 'u')
313+
.where('u.reputation > :minRep', { minRep: 10 })
314+
.andWhere('u.bio IS NOT NULL')
315+
.andWhere(`btrim(u.bio) != ''`)
316+
.andWhere('u.username IS NOT NULL')
317+
.andWhere((qb) => {
318+
const subQuery = qb
319+
.subQuery()
320+
.select('1')
321+
.from(Post, 'p')
322+
.where('p."authorId" = u.id')
323+
.andWhere('p.deleted = false')
324+
.andWhere('p.visible = true')
325+
.andWhere('p.private = false')
326+
.getQuery();
327+
328+
return `EXISTS ${subQuery}`;
329+
})
330+
.orderBy('u.reputation', 'DESC')
331+
.addOrderBy('u.username', 'ASC')
332+
.limit(DEFAULT_SITEMAP_LIMIT);
333+
304334
const getPostsSitemapPath = (page: number): string =>
305335
page === 1 ? '/api/sitemaps/posts-1.xml' : `/api/sitemaps/posts-${page}.xml`;
306336

@@ -347,6 +377,9 @@ ${evergreenSitemaps}
347377
<sitemap>
348378
<loc>${escapeXml(`${prefix}/api/sitemaps/squads.xml`)}</loc>
349379
</sitemap>
380+
<sitemap>
381+
<loc>${escapeXml(`${prefix}/api/sitemaps/users.xml`)}</loc>
382+
</sitemap>
350383
</sitemapindex>`;
351384
};
352385

@@ -533,6 +566,22 @@ export default async function (fastify: FastifyInstance): Promise<void> {
533566
);
534567
});
535568

569+
fastify.get('/users.xml', async (_, res) => {
570+
const con = await createOrGetConnection();
571+
const input = await streamReplicaQuery(con, buildUsersSitemapQuery);
572+
573+
return res
574+
.type('application/xml')
575+
.header('cache-control', SITEMAP_CACHE_CONTROL)
576+
.send(
577+
toSitemapUrlSetStream(
578+
input,
579+
(row) => getUserProfileUrl(row.username),
580+
getSitemapRowLastmod,
581+
),
582+
);
583+
});
584+
536585
fastify.get('/index.xml', async (_, res) => {
537586
const con = await createOrGetConnection();
538587
const postsSitemapCount = getSitemapPageCount(

0 commit comments

Comments
 (0)