Skip to content

Commit 0112b8e

Browse files
authored
feat: create post www dedup (#3946)
1 parent bdc7956 commit 0112b8e

6 files changed

Lines changed: 261 additions & 7 deletions

File tree

__tests__/posts.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import {
5353
postTagsFixture,
5454
videoPostsFixture,
5555
} from './fixture/post';
56-
import { DataSource, DeepPartial, IsNull, Not } from 'typeorm';
56+
import { DataSource, DeepPartial, In, IsNull, Not } from 'typeorm';
5757
import createOrGetConnection from '../src/db';
5858
import {
5959
createSquadWelcomePost,
@@ -3958,6 +3958,52 @@ describe('mutation createPostInMultipleSources', () => {
39583958
expect(moderationItem.sharedPostId).toBe(existingArticle.id);
39593959
expect(userSourcePost.sharedPostId).toBe(existingArticle.id);
39603960
});
3961+
3962+
it('should share existing post when url differs only by www', async () => {
3963+
loggedUser = '1';
3964+
await con.getRepository(ArticlePost).save({
3965+
id: 'pmswww',
3966+
shortId: 'pmswww',
3967+
url: 'http://www.multisource-www.com/a',
3968+
canonicalUrl: 'http://www.multisource-www.com/a',
3969+
sourceId: 'a',
3970+
visible: true,
3971+
createdAt: new Date(),
3972+
type: PostType.Article,
3973+
origin: PostOrigin.Squad,
3974+
});
3975+
3976+
const res = await client.mutate(MUTATION, {
3977+
variables: {
3978+
...freeformParams,
3979+
externalLink: 'http://multisource-www.com/a',
3980+
},
3981+
});
3982+
3983+
expect(res.errors).toBeFalsy();
3984+
const [first, second, third] = res.data.createPostInMultipleSources;
3985+
3986+
const [post, moderationItem, userSourcePost] = await Promise.all([
3987+
con.getRepository(SharePost).findOneByOrFail({ id: first.id }),
3988+
con.getRepository(SourcePostModeration).findOneOrFail({
3989+
select: ['sharedPostId'],
3990+
where: { id: second.id },
3991+
}),
3992+
con.getRepository(SharePost).findOneByOrFail({ id: third.id }),
3993+
]);
3994+
3995+
expect(post.sharedPostId).toBe('pmswww');
3996+
expect(moderationItem.sharedPostId).toBe('pmswww');
3997+
expect(userSourcePost.sharedPostId).toBe('pmswww');
3998+
3999+
const articlePosts = await con.getRepository(ArticlePost).findBy({
4000+
url: In([
4001+
'http://multisource-www.com/a',
4002+
'http://www.multisource-www.com/a',
4003+
]),
4004+
});
4005+
expect(articlePosts).toHaveLength(1);
4006+
});
39614007
});
39624008

39634009
describe('poll post creation', () => {
@@ -4587,6 +4633,25 @@ describe('mutation submitExternalLink', () => {
45874633
expect(sharedPost.visible).toEqual(true);
45884634
});
45894635

4636+
it('should share existing post to squad when url differs only by www', async () => {
4637+
loggedUser = '1';
4638+
const res = await client.mutate(MUTATION, {
4639+
variables: { ...variables, url: 'http://www.p6.com' },
4640+
});
4641+
expect(res.errors).toBeFalsy();
4642+
4643+
const articlePosts = await con
4644+
.getRepository(ArticlePost)
4645+
.findBy({ url: In(['http://p6.com', 'http://www.p6.com']) });
4646+
expect(articlePosts).toHaveLength(1);
4647+
expect(articlePosts[0].id).toEqual('p6');
4648+
4649+
const sharedPost = await con
4650+
.getRepository(SharePost)
4651+
.findOneByOrFail({ sharedPostId: 'p6' });
4652+
expect(sharedPost.authorId).toEqual('1');
4653+
});
4654+
45904655
it('should trigger content request when sharing existing post without title and summary', async () => {
45914656
loggedUser = '1';
45924657

__tests__/routes/private/rpc.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
SourceType,
88
} from '../../../src/entity';
99
import { sourcesFixture } from '../../fixture/source';
10-
import { DataSource } from 'typeorm';
10+
import { DataSource, In } from 'typeorm';
1111
import createOrGetConnection from '../../../src/db';
1212
import {
1313
PageIngestion,
@@ -174,6 +174,98 @@ describe('PostService', () => {
174174
});
175175
});
176176

177+
it('should deduplicate www against non www urls', async () => {
178+
const { postId } = await mockClient.create(
179+
{
180+
url: 'http://www.example.com/service/1',
181+
sourceId: 'a',
182+
},
183+
defaultClientAuthOptions,
184+
);
185+
186+
const result = await mockClient.create(
187+
{
188+
url: 'http://example.com/service/1',
189+
sourceId: 'a',
190+
},
191+
defaultClientAuthOptions,
192+
);
193+
194+
expect(result).toEqual({
195+
postId,
196+
url: 'http://www.example.com/service/1',
197+
});
198+
199+
const posts = await con.getRepository(ArticlePost).findBy({
200+
url: In([
201+
'http://example.com/service/1',
202+
'http://www.example.com/service/1',
203+
]),
204+
});
205+
expect(posts).toHaveLength(1);
206+
expect(posts[0].url).toEqual('http://www.example.com/service/1');
207+
});
208+
209+
it('should deduplicate non www against www urls', async () => {
210+
const { postId } = await mockClient.create(
211+
{
212+
url: 'http://example.com/service/2',
213+
sourceId: 'a',
214+
},
215+
defaultClientAuthOptions,
216+
);
217+
218+
const result = await mockClient.create(
219+
{
220+
url: 'http://www.example.com/service/2',
221+
sourceId: 'a',
222+
},
223+
defaultClientAuthOptions,
224+
);
225+
226+
expect(result).toEqual({
227+
postId,
228+
url: 'http://example.com/service/2',
229+
});
230+
231+
const posts = await con.getRepository(ArticlePost).findBy({
232+
url: In([
233+
'http://example.com/service/2',
234+
'http://www.example.com/service/2',
235+
]),
236+
});
237+
expect(posts).toHaveLength(1);
238+
expect(posts[0].url).toEqual('http://example.com/service/2');
239+
});
240+
241+
it('should not deduplicate other subdomain urls', async () => {
242+
const first = await mockClient.create(
243+
{
244+
url: 'http://example.com/service/3',
245+
sourceId: 'a',
246+
},
247+
defaultClientAuthOptions,
248+
);
249+
250+
const second = await mockClient.create(
251+
{
252+
url: 'http://blog.example.com/service/3',
253+
sourceId: 'a',
254+
},
255+
defaultClientAuthOptions,
256+
);
257+
258+
expect(second.postId).not.toEqual(first.postId);
259+
260+
const posts = await con.getRepository(ArticlePost).findBy({
261+
url: In([
262+
'http://example.com/service/3',
263+
'http://blog.example.com/service/3',
264+
]),
265+
});
266+
expect(posts).toHaveLength(2);
267+
});
268+
177269
it('should throw on invalid source', async () => {
178270
await expect(
179271
mockClient.create(

__tests__/workers/cdc/primary.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6333,6 +6333,38 @@ describe('source_post_moderation', () => {
63336333
expect(list.length).toEqual(3); // to ensure nothing new was created other than the share post
63346334
});
63356335

6336+
it('should create share post from external link that differs only by www', async () => {
6337+
const repo = con.getRepository(Post);
6338+
const existing = await repo.save({
6339+
...postsFixture[0],
6340+
sourceId: 'c',
6341+
url: 'https://www.daily.dev/blog-post/sauron',
6342+
canonicalUrl: 'https://www.daily.dev/blog-post/sauron',
6343+
});
6344+
const before = await repo.find();
6345+
expect(before.length).toEqual(2);
6346+
const after = {
6347+
...base,
6348+
sourceId: 'a',
6349+
type: PostType.Share,
6350+
status: SourcePostModerationStatus.Approved,
6351+
title: 'Test',
6352+
content: '# Sample',
6353+
contentHtml: '# Sample',
6354+
externalLink: 'https://daily.dev/blog-post/sauron',
6355+
};
6356+
await mockUpdate(after);
6357+
const share = (await repo.findOneBy({
6358+
sourceId: 'a',
6359+
})) as SharePost;
6360+
expect(share).toBeTruthy();
6361+
expect(share.type).toEqual(PostType.Share);
6362+
expect(share.sharedPostId).toEqual(existing.id);
6363+
6364+
const list = await repo.find();
6365+
expect(list.length).toEqual(3); // deduped to the www variant, only the share was added
6366+
});
6367+
63366368
it('should update the content if post id is present', async () => {
63376369
const repo = con.getRepository(Post);
63386370
await saveFixtures(con, Post, [postsFixture[0]]);

src/common/links.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,19 @@ const subtractDomain = (url: string): string | null => {
8484
return matches && matches[1];
8585
};
8686

87+
// The same article is often reachable under both www. and non-www. hosts.
88+
// Return the url alongside its www.-toggled form so dedup lookups can match
89+
// either without rewriting the url we persist.
90+
export const getUrlWwwVariants = (url: string): string[] => {
91+
const match = url?.match(/^(https?:\/\/)(www\.)?(.*)$/i);
92+
if (!match) {
93+
return [url];
94+
}
95+
96+
const [, scheme, www, rest] = match;
97+
return [url, www ? `${scheme}${rest}` : `${scheme}www.${rest}`];
98+
};
99+
87100
export const standardizeURL = (
88101
inputUrl: string,
89102
): { url: string; canonicalUrl: string } => {

src/common/post.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
WelcomePost,
2626
} from '../entity';
2727
import { ForbiddenError, ValidationError } from 'apollo-server-errors';
28-
import { isValidHttpUrl, standardizeURL } from './links';
28+
import { getUrlWwwVariants, isValidHttpUrl, standardizeURL } from './links';
2929
import {
3030
findMarkdownTag,
3131
markdown,
@@ -915,7 +915,10 @@ export const getExistingPost = async (
915915
manager
916916
.createQueryBuilder(Post, 'post')
917917
.select(['post.id', 'post.deleted', 'post.visible'])
918-
.where([{ canonicalUrl: canonicalUrl }, { url: url }])
918+
.where([
919+
{ canonicalUrl: In(getUrlWwwVariants(canonicalUrl)) },
920+
{ url: In(getUrlWwwVariants(url)) },
921+
])
919922
.getOne();
920923

921924
const extractPostIdOrSlugFromUrl = (url: string): string | undefined => {
@@ -963,8 +966,8 @@ export const findPostByUrl = async <T extends keyof ArticlePost>(
963966
]);
964967
} else {
965968
queryBuilder = queryBuilder.andWhere([
966-
{ canonicalUrl: canonicalUrl },
967-
{ url: cleanUrl },
969+
{ canonicalUrl: In(getUrlWwwVariants(canonicalUrl)) },
970+
{ url: In(getUrlWwwVariants(cleanUrl)) },
968971
]);
969972
}
970973

src/routes/private/rpc.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
} from '@dailydotdev/schema';
1010
import { ArticlePost, SourceRequest } from '../../entity';
1111
import { baseRpcContext } from '../../common/connectRpc';
12-
import { isValidHttpUrl, standardizeURL } from '../../common/links';
12+
import {
13+
getUrlWwwVariants,
14+
isValidHttpUrl,
15+
standardizeURL,
16+
} from '../../common/links';
1317
import createOrGetConnection from '../../db';
1418
import { TypeOrmError, TypeORMQueryFailedError } from '../../errors';
1519
import { generateShortId } from '../../ids';
@@ -47,6 +51,46 @@ const getDuplicatePost = async ({
4751
}
4852
};
4953

54+
/**
55+
* Check www or non www variant of url to prevent duplicates
56+
*
57+
* Unique index only covers exact match
58+
*
59+
* @param {{
60+
* req: CreatePostRequest;
61+
* con: DataSource;
62+
* }} {
63+
* req,
64+
* con,
65+
* }
66+
* @return {*} {(Promise<CreatePostResponse | undefined>)}
67+
*/
68+
const getWwwVariantPost = async ({
69+
req,
70+
con,
71+
}: {
72+
req: CreatePostRequest;
73+
con: DataSource;
74+
}): Promise<CreatePostResponse | undefined> => {
75+
const variantUrl = getUrlWwwVariants(req.url).find((url) => url !== req.url);
76+
if (req.yggdrasilId || !variantUrl) {
77+
return undefined;
78+
}
79+
80+
const existingPost = await con
81+
.getRepository(ArticlePost)
82+
.findOneBy({ url: variantUrl });
83+
84+
if (!existingPost) {
85+
return undefined;
86+
}
87+
88+
return new CreatePostResponse({
89+
postId: existingPost.id,
90+
url: existingPost.url || undefined,
91+
});
92+
};
93+
5094
const validateCreatePostRequest = (req: CreatePostRequest): never | void => {
5195
// collection-style sources (collections + trends) are yggdrasil-generated
5296
// and have no URL; they require a yggdrasil id instead.
@@ -80,6 +124,11 @@ export default function (router: ConnectRouter) {
80124

81125
validateCreatePostRequest(req);
82126

127+
const wwwVariantPost = await getWwwVariantPost({ req, con });
128+
if (wwwVariantPost) {
129+
return wwwVariantPost;
130+
}
131+
83132
const postId = await generateShortId();
84133
const postEntity = con.getRepository(ArticlePost).create({
85134
...req,

0 commit comments

Comments
 (0)