From c993fa11be43dc7745da193a86cf384b0f0cee73 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 29 Nov 2022 11:39:23 +0800 Subject: [PATCH 1/5] Add stack-overflow content-handler --- packages/content-handler/src/index.ts | 2 + .../src/websites/stack-overflow-handler.ts | 93 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 packages/content-handler/src/websites/stack-overflow-handler.ts diff --git a/packages/content-handler/src/index.ts b/packages/content-handler/src/index.ts index e210d025d..b04d997b9 100644 --- a/packages/content-handler/src/index.ts +++ b/packages/content-handler/src/index.ts @@ -29,6 +29,7 @@ import { parseHTML } from 'linkedom' import { CooperPressHandler } from './newsletters/cooper-press-handler' import { HeyWorldHandler } from './newsletters/hey-world-handler' import { Browser } from 'puppeteer-core' +import { StackOverflowHandler } from './websites/stack-overflow-handler' const validateUrlString = (url: string) => { const u = new URL(url) @@ -64,6 +65,7 @@ const contentHandlers: ContentHandler[] = [ new MorningBrewHandler(), new BloombergNewsletterHandler(), new SubstackHandler(), + new StackOverflowHandler(), ] const newsletterHandlers: ContentHandler[] = [ diff --git a/packages/content-handler/src/websites/stack-overflow-handler.ts b/packages/content-handler/src/websites/stack-overflow-handler.ts new file mode 100644 index 000000000..e4c5b9c15 --- /dev/null +++ b/packages/content-handler/src/websites/stack-overflow-handler.ts @@ -0,0 +1,93 @@ +import { ContentHandler } from '../content-handler' + +export class StackOverflowHandler extends ContentHandler { + constructor() { + super() + this.name = 'stackoverflow' + } + + parseVotes(element: Element, dom: Document, title: string) { + const votes = element.querySelector(`div[itemprop='upvoteCount']`) + if (votes) { + const newVotes = dom.createElement('div') + newVotes.innerHTML = `

${title}: ${votes.innerHTML}votes

` + element.prepend(newVotes) + } + } + + parseComments(element: Element, dom: Document) { + // comments + const commentsDiv = element.querySelector(`.comments`) + if (commentsDiv) { + const comments = commentsDiv.querySelectorAll(`.comment`) + if (comments.length > 0) { + const count = element.querySelector( + `span[itemprop='commentCount']` + )?.textContent + + const newComments = dom.createElement('div') + newComments.innerHTML = `

${ + count ? count + ' Comments' : 'Comment' + }

` + + comments.forEach((comment) => { + const author = comment.querySelector(`.comment-user`) + const text = comment.querySelector(`.comment-copy`)?.textContent + const authorHref = author?.getAttribute('href') + const date = comment.querySelector(`.relativetime-clean`)?.textContent + const link = comment + .querySelector(`.comment-link`) + ?.getAttribute('href') + if (author && text && authorHref && date && link) { + const newComment = dom.createElement('p') + newComment.innerHTML = `${author.innerHTML}: ${text} - ${date}` + newComments.appendChild(newComment) + } + }) + commentsDiv.parentNode?.replaceChild(newComments, commentsDiv) + } + } + + // remove comment count + element.querySelector(`span[itemprop='commentCount']`)?.remove() + } + + shouldPreParse(url: string, dom: Document): boolean { + return new URL(url).hostname.endsWith('stackoverflow.com') + } + + async preParse(url: string, dom: Document): Promise { + const mainEntity = dom.querySelector(`div[itemprop='mainEntity']`) + if (mainEntity) { + const question = mainEntity.querySelector('.question') + if (question) { + this.parseVotes(question, dom, 'Question') + this.parseComments(question, dom) + } + + const answersDiv = mainEntity.querySelector('#answers') + if (answersDiv) { + const count = mainEntity.querySelector( + `span[itemprop='answerCount']` + )?.textContent + const newAnswers = dom.createElement('div') + newAnswers.innerHTML = `

${ + count ? count + ' Answers' : 'Answer' + }

` + + const answers = answersDiv.querySelectorAll(`.answer`) + answers.forEach((answer) => { + const title = answer.classList.contains('accepted-answer') + ? 'Accepted Answer' + : 'Answer' + this.parseVotes(answer, dom, title) + this.parseComments(answer, dom) + newAnswers.appendChild(answer) + }) + answersDiv.replaceChildren(newAnswers) + } + } + + return Promise.resolve(dom) + } +} From 8ee19a98fa86a281eef276c884e2d9530b24d0b5 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 29 Nov 2022 12:26:37 +0800 Subject: [PATCH 2/5] parse authors --- .../src/websites/stack-overflow-handler.ts | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/packages/content-handler/src/websites/stack-overflow-handler.ts b/packages/content-handler/src/websites/stack-overflow-handler.ts index e4c5b9c15..de937dc33 100644 --- a/packages/content-handler/src/websites/stack-overflow-handler.ts +++ b/packages/content-handler/src/websites/stack-overflow-handler.ts @@ -10,7 +10,7 @@ export class StackOverflowHandler extends ContentHandler { const votes = element.querySelector(`div[itemprop='upvoteCount']`) if (votes) { const newVotes = dom.createElement('div') - newVotes.innerHTML = `

${title}: ${votes.innerHTML}votes

` + newVotes.innerHTML = `

${title}: ${votes.innerHTML}vote(s)

` element.prepend(newVotes) } } @@ -21,26 +21,24 @@ export class StackOverflowHandler extends ContentHandler { if (commentsDiv) { const comments = commentsDiv.querySelectorAll(`.comment`) if (comments.length > 0) { - const count = element.querySelector( - `span[itemprop='commentCount']` - )?.textContent + // const count = element.querySelector( + // `span[itemprop='commentCount']` + // )?.textContent const newComments = dom.createElement('div') - newComments.innerHTML = `

${ - count ? count + ' Comments' : 'Comment' - }

` + newComments.innerHTML = `

Comments

` + // newComments.innerHTML = `

${ + // count ? count + ' Comments' : 'Comment' + // }

` comments.forEach((comment) => { const author = comment.querySelector(`.comment-user`) const text = comment.querySelector(`.comment-copy`)?.textContent const authorHref = author?.getAttribute('href') const date = comment.querySelector(`.relativetime-clean`)?.textContent - const link = comment - .querySelector(`.comment-link`) - ?.getAttribute('href') - if (author && text && authorHref && date && link) { + if (author && text && authorHref && date) { const newComment = dom.createElement('p') - newComment.innerHTML = `${author.innerHTML}: ${text} - ${date}` + newComment.innerHTML = `${author.innerHTML}: ${text} - ${date}` newComments.appendChild(newComment) } }) @@ -52,6 +50,26 @@ export class StackOverflowHandler extends ContentHandler { element.querySelector(`span[itemprop='commentCount']`)?.remove() } + parseUser(element: Element, dom: Document) { + const users = element.querySelectorAll(`.post-signature`) + users.forEach((user) => { + const name = user.querySelector(`.user-details a`)?.textContent + const link = user.querySelector(`.user-details a`)?.getAttribute('href') + const reputation = user.querySelector(`.reputation-score`)?.textContent + const badges = Array.from(user.querySelectorAll(`span[title*='badges']`)) + .map((badge) => badge.getAttribute('title')) + .join(', ') + const date = user.querySelector(`.user-action-time`)?.textContent + if (name && link && reputation && date) { + const newUser = dom.createElement('p') + newUser.innerHTML = `By ${name} - ${reputation} reputation - ${ + badges || 'no badge' + } - ${date}` + element.replaceChild(newUser, user) + } + }) + } + shouldPreParse(url: string, dom: Document): boolean { return new URL(url).hostname.endsWith('stackoverflow.com') } @@ -63,17 +81,19 @@ export class StackOverflowHandler extends ContentHandler { if (question) { this.parseVotes(question, dom, 'Question') this.parseComments(question, dom) + this.parseUser(question, dom) } const answersDiv = mainEntity.querySelector('#answers') if (answersDiv) { - const count = mainEntity.querySelector( - `span[itemprop='answerCount']` - )?.textContent + // const count = mainEntity.querySelector( + // `span[itemprop='answerCount']` + // )?.textContent const newAnswers = dom.createElement('div') - newAnswers.innerHTML = `

${ - count ? count + ' Answers' : 'Answer' - }

` + newAnswers.innerHTML = `

Answers

` + // newAnswers.innerHTML = `

${ + // count ? count + ' Answers' : 'Answer' + // }

` const answers = answersDiv.querySelectorAll(`.answer`) answers.forEach((answer) => { @@ -82,6 +102,7 @@ export class StackOverflowHandler extends ContentHandler { : 'Answer' this.parseVotes(answer, dom, title) this.parseComments(answer, dom) + this.parseUser(answer, dom) newAnswers.appendChild(answer) }) answersDiv.replaceChildren(newAnswers) From f8e0efad05524467251d2a66829282e5558b46c2 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 29 Nov 2022 15:40:22 +0800 Subject: [PATCH 3/5] fix bug --- .../src/websites/stack-overflow-handler.ts | 92 +++++++++---------- 1 file changed, 44 insertions(+), 48 deletions(-) diff --git a/packages/content-handler/src/websites/stack-overflow-handler.ts b/packages/content-handler/src/websites/stack-overflow-handler.ts index de937dc33..ecfe77029 100644 --- a/packages/content-handler/src/websites/stack-overflow-handler.ts +++ b/packages/content-handler/src/websites/stack-overflow-handler.ts @@ -6,30 +6,29 @@ export class StackOverflowHandler extends ContentHandler { this.name = 'stackoverflow' } - parseVotes(element: Element, dom: Document, title: string) { - const votes = element.querySelector(`div[itemprop='upvoteCount']`) - if (votes) { - const newVotes = dom.createElement('div') - newVotes.innerHTML = `

${title}: ${votes.innerHTML}vote(s)

` - element.prepend(newVotes) + parseText(element: Element, title: string) { + const newText = element.ownerDocument.createElement('div') + const text = element.querySelector(`div[itemprop='text']`) + if (text) { + const votes = element.querySelector(`div[itemprop='upvoteCount']`) + + if (votes) { + newText.innerHTML = `

${title}:${votes.innerHTML}vote(s)

${text.innerHTML}
` + } } + return newText } - parseComments(element: Element, dom: Document) { + parseComments(element: Element) { + const dom = element.ownerDocument + const newComments = dom.createElement('div') + // comments const commentsDiv = element.querySelector(`.comments`) if (commentsDiv) { const comments = commentsDiv.querySelectorAll(`.comment`) if (comments.length > 0) { - // const count = element.querySelector( - // `span[itemprop='commentCount']` - // )?.textContent - - const newComments = dom.createElement('div') - newComments.innerHTML = `

Comments

` - // newComments.innerHTML = `

${ - // count ? count + ' Comments' : 'Comment' - // }

` + newComments.innerHTML = `

Comments

` comments.forEach((comment) => { const author = comment.querySelector(`.comment-user`) @@ -38,36 +37,41 @@ export class StackOverflowHandler extends ContentHandler { const date = comment.querySelector(`.relativetime-clean`)?.textContent if (author && text && authorHref && date) { const newComment = dom.createElement('p') - newComment.innerHTML = `${author.innerHTML}: ${text} - ${date}` + newComment.innerHTML = `${author.innerHTML}: ${text} - ${date}` newComments.appendChild(newComment) } }) - commentsDiv.parentNode?.replaceChild(newComments, commentsDiv) } } - // remove comment count - element.querySelector(`span[itemprop='commentCount']`)?.remove() + return newComments } - parseUser(element: Element, dom: Document) { - const users = element.querySelectorAll(`.post-signature`) - users.forEach((user) => { - const name = user.querySelector(`.user-details a`)?.textContent - const link = user.querySelector(`.user-details a`)?.getAttribute('href') - const reputation = user.querySelector(`.reputation-score`)?.textContent - const badges = Array.from(user.querySelectorAll(`span[title*='badges']`)) + parseAuthors(element: Element) { + const dom = element.ownerDocument + const newAuthors = dom.createElement('div') + + const authors = element.querySelectorAll(`.post-signature`) + authors.forEach((author) => { + const name = author.querySelector(`.user-details a`)?.textContent + const link = author.querySelector(`.user-details a`)?.getAttribute('href') + const reputation = author.querySelector(`.reputation-score`)?.textContent + const badges = Array.from( + author.querySelectorAll(`span[title*='badges']`) + ) .map((badge) => badge.getAttribute('title')) .join(', ') - const date = user.querySelector(`.user-action-time`)?.textContent + const date = author.querySelector(`.user-action-time`)?.textContent if (name && link && reputation && date) { - const newUser = dom.createElement('p') - newUser.innerHTML = `By ${name} - ${reputation} reputation - ${ + const newAuthor = dom.createElement('p') + newAuthor.innerHTML = `${name} - ${reputation} reputation - ${ badges || 'no badge' } - ${date}` - element.replaceChild(newUser, user) + newAuthors.appendChild(newAuthor) } }) + + return newAuthors } shouldPreParse(url: string, dom: Document): boolean { @@ -77,36 +81,28 @@ export class StackOverflowHandler extends ContentHandler { async preParse(url: string, dom: Document): Promise { const mainEntity = dom.querySelector(`div[itemprop='mainEntity']`) if (mainEntity) { + const newMainEntity = dom.createElement('div') const question = mainEntity.querySelector('.question') if (question) { - this.parseVotes(question, dom, 'Question') - this.parseComments(question, dom) - this.parseUser(question, dom) + newMainEntity.appendChild(this.parseText(question, 'Question')) + newMainEntity.appendChild(this.parseAuthors(question)) + newMainEntity.appendChild(this.parseComments(question)) } const answersDiv = mainEntity.querySelector('#answers') if (answersDiv) { - // const count = mainEntity.querySelector( - // `span[itemprop='answerCount']` - // )?.textContent - const newAnswers = dom.createElement('div') - newAnswers.innerHTML = `

Answers

` - // newAnswers.innerHTML = `

${ - // count ? count + ' Answers' : 'Answer' - // }

` - const answers = answersDiv.querySelectorAll(`.answer`) answers.forEach((answer) => { const title = answer.classList.contains('accepted-answer') ? 'Accepted Answer' : 'Answer' - this.parseVotes(answer, dom, title) - this.parseComments(answer, dom) - this.parseUser(answer, dom) - newAnswers.appendChild(answer) + newMainEntity.appendChild(this.parseText(answer, title)) + newMainEntity.appendChild(this.parseAuthors(answer)) + newMainEntity.appendChild(this.parseComments(answer)) }) - answersDiv.replaceChildren(newAnswers) } + + dom.body.replaceChildren(newMainEntity) } return Promise.resolve(dom) From 1fe86b6230ee3fefffc83f6fc21accf332d2b739 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 29 Nov 2022 15:48:45 +0800 Subject: [PATCH 4/5] Parse owner --- .../content-handler/src/websites/stack-overflow-handler.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/content-handler/src/websites/stack-overflow-handler.ts b/packages/content-handler/src/websites/stack-overflow-handler.ts index ecfe77029..384922e88 100644 --- a/packages/content-handler/src/websites/stack-overflow-handler.ts +++ b/packages/content-handler/src/websites/stack-overflow-handler.ts @@ -53,6 +53,7 @@ export class StackOverflowHandler extends ContentHandler { const authors = element.querySelectorAll(`.post-signature`) authors.forEach((author) => { + const isOwner = author.classList.contains('owner') const name = author.querySelector(`.user-details a`)?.textContent const link = author.querySelector(`.user-details a`)?.getAttribute('href') const reputation = author.querySelector(`.reputation-score`)?.textContent @@ -67,6 +68,12 @@ export class StackOverflowHandler extends ContentHandler { newAuthor.innerHTML = `${name} - ${reputation} reputation - ${ badges || 'no badge' } - ${date}` + if (isOwner) { + const author = dom.createElement('span') + author.setAttribute('rel', 'author') + author.innerHTML = name + newAuthor.appendChild(author) + } newAuthors.appendChild(newAuthor) } }) From 3a3a06b5d60fc919fe7af6457daf6ad2001f1941 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 29 Nov 2022 16:18:09 +0800 Subject: [PATCH 5/5] Fix vote count --- .../src/websites/stack-overflow-handler.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/content-handler/src/websites/stack-overflow-handler.ts b/packages/content-handler/src/websites/stack-overflow-handler.ts index 384922e88..21960dc33 100644 --- a/packages/content-handler/src/websites/stack-overflow-handler.ts +++ b/packages/content-handler/src/websites/stack-overflow-handler.ts @@ -10,10 +10,14 @@ export class StackOverflowHandler extends ContentHandler { const newText = element.ownerDocument.createElement('div') const text = element.querySelector(`div[itemprop='text']`) if (text) { - const votes = element.querySelector(`div[itemprop='upvoteCount']`) + const votes = element + .querySelector(`div[itemprop='upvoteCount']`) + ?.getAttribute('data-value') if (votes) { - newText.innerHTML = `

${title}:${votes.innerHTML}vote(s)

${text.innerHTML}
` + newText.innerHTML = `

${title}: ${votes} vote${ + votes === '1' ? '' : 's' + }

${text.innerHTML}` } } return newText @@ -89,7 +93,7 @@ export class StackOverflowHandler extends ContentHandler { const mainEntity = dom.querySelector(`div[itemprop='mainEntity']`) if (mainEntity) { const newMainEntity = dom.createElement('div') - const question = mainEntity.querySelector('.question') + const question = mainEntity.querySelector('#question') if (question) { newMainEntity.appendChild(this.parseText(question, 'Question')) newMainEntity.appendChild(this.parseAuthors(question))