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..21960dc33 --- /dev/null +++ b/packages/content-handler/src/websites/stack-overflow-handler.ts @@ -0,0 +1,121 @@ +import { ContentHandler } from '../content-handler' + +export class StackOverflowHandler extends ContentHandler { + constructor() { + super() + this.name = 'stackoverflow' + } + + 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']`) + ?.getAttribute('data-value') + + if (votes) { + newText.innerHTML = `

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

${text.innerHTML}` + } + } + return newText + } + + 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) { + newComments.innerHTML = `

Comments

` + + 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 + if (author && text && authorHref && date) { + const newComment = dom.createElement('p') + newComment.innerHTML = `${author.innerHTML}: ${text} - ${date}` + newComments.appendChild(newComment) + } + }) + } + } + + return newComments + } + + parseAuthors(element: Element) { + const dom = element.ownerDocument + const newAuthors = dom.createElement('div') + + 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 + const badges = Array.from( + author.querySelectorAll(`span[title*='badges']`) + ) + .map((badge) => badge.getAttribute('title')) + .join(', ') + const date = author.querySelector(`.user-action-time`)?.textContent + if (name && link && reputation && date) { + const newAuthor = dom.createElement('p') + 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) + } + }) + + return newAuthors + } + + 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 newMainEntity = dom.createElement('div') + const question = mainEntity.querySelector('#question') + if (question) { + newMainEntity.appendChild(this.parseText(question, 'Question')) + newMainEntity.appendChild(this.parseAuthors(question)) + newMainEntity.appendChild(this.parseComments(question)) + } + + const answersDiv = mainEntity.querySelector('#answers') + if (answersDiv) { + const answers = answersDiv.querySelectorAll(`.answer`) + answers.forEach((answer) => { + const title = answer.classList.contains('accepted-answer') + ? 'Accepted Answer' + : 'Answer' + newMainEntity.appendChild(this.parseText(answer, title)) + newMainEntity.appendChild(this.parseAuthors(answer)) + newMainEntity.appendChild(this.parseComments(answer)) + }) + } + + dom.body.replaceChildren(newMainEntity) + } + + return Promise.resolve(dom) + } +}