Skip to content

Commit 8521924

Browse files
author
Gary Blankenship
committed
feat(extractors): port leetcode, discourse, linkedin — complete upstream parity
Ports the final three upstream extractors, achieving full parity with kepano/defuddle (23 of 23 upstream site extractors ported): LeetCode: matches leetcode.com; CanExtract() gates on `data-track-load="description_content"` attribute on the problem description div; preserves code-block `<pre><code>` structure so language-tagged code samples survive extraction intact. Discourse: DOM-signature detection via `meta[name="generator"]` content matching "Discourse"; extracts post content from `.topic-post` containers. Does not use a URL pattern — Discourse instances run on arbitrary domains, so detection is fingerprint-based, not hostname-based. Shares the wildcard catch-all slot with Mastodon; Discourse is tried first because some Discourse instances serve ActivityPub endpoints that contain Mastodon-style structural selectors (false positive risk without ordering). LinkedIn: matches linkedin.com; covers publicly-accessible pulse articles and feed posts — no login-wall bypass; login-gated pages return nil from CanExtract(). Implementation split across three files for cohesion: linkedin.go (core extractor + CanExtract), linkedin_content.go (article and post body extraction), linkedin_comments.go (comment thread parsing). Registry: adds LeetCode and LinkedIn as URL-pattern entries (count 19→21); replaces the Mastodon-only catch-all with a combined Discourse+Mastodon catch-all that tries Discourse first via DOM fingerprint, then Mastodon. This commit completes full upstream parity: 23 of 23 upstream extractors ported. All 821 tests pass.
1 parent 43bff00 commit 8521924

10 files changed

Lines changed: 1258 additions & 8 deletions

extractors/discourse.go

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
package extractors
2+
3+
import (
4+
"fmt"
5+
"html"
6+
"net/url"
7+
"strings"
8+
"time"
9+
10+
"github.com/PuerkitoBio/goquery"
11+
)
12+
13+
// DiscourseExtractor handles forum content extraction from sites running the
14+
// Discourse platform. Detection uses the meta[name="generator"] value rather
15+
// than URL patterns — Discourse is installed at arbitrary domains.
16+
//
17+
// Registration note: must appear BEFORE the Mastodon catch-all in initializeBuiltins.
18+
//
19+
// TypeScript original:
20+
//
21+
// export class DiscourseExtractor extends BaseExtractor {
22+
// private isDiscourse: boolean;
23+
// constructor(...) {
24+
// const generator = document.querySelector('meta[name="generator"]')?.getAttribute('content') || '';
25+
// this.isDiscourse = generator.startsWith('Discourse');
26+
// }
27+
// canExtract(): boolean { return this.isDiscourse && !!document.querySelector('.topic-post'); }
28+
// }
29+
type DiscourseExtractor struct {
30+
*ExtractorBase
31+
isDiscourse bool
32+
}
33+
34+
// NewDiscourseExtractor creates a new Discourse extractor and sniffs the generator tag.
35+
func NewDiscourseExtractor(document *goquery.Document, rawURL string, schemaOrgData any) *DiscourseExtractor {
36+
generator, _ := document.Find(`meta[name="generator"]`).First().Attr("content")
37+
return &DiscourseExtractor{
38+
ExtractorBase: NewExtractorBase(document, rawURL, schemaOrgData),
39+
isDiscourse: strings.HasPrefix(generator, "Discourse"),
40+
}
41+
}
42+
43+
// Name returns the extractor identifier.
44+
func (e *DiscourseExtractor) Name() string { return "DiscourseExtractor" }
45+
46+
// CanExtract returns true when the page has a Discourse generator meta AND
47+
// at least one .topic-post element. Both conditions must hold to avoid false
48+
// positives on pages that embed a Discourse widget without the full SPA.
49+
func (e *DiscourseExtractor) CanExtract() bool {
50+
return e.isDiscourse && e.GetDocument().Find(".topic-post").Length() > 0
51+
}
52+
53+
// Extract returns the structured content for a Discourse topic page.
54+
func (e *DiscourseExtractor) Extract() *ExtractorResult {
55+
doc := e.GetDocument()
56+
57+
title := e.getTopicTitle(doc)
58+
siteName, _ := doc.Find(`meta[property="og:site_name"]`).First().Attr("content")
59+
category := strings.TrimSpace(doc.Find(".badge-category__name").First().Text())
60+
tags := e.getTags(doc)
61+
published := e.getPublishedDate(doc)
62+
63+
posts := doc.Find(".topic-post")
64+
var op *goquery.Selection
65+
posts.Each(func(_ int, s *goquery.Selection) {
66+
if op == nil && s.HasClass("topic-owner") {
67+
op = s
68+
}
69+
})
70+
71+
postContent := ""
72+
opAuthor := ""
73+
if op != nil {
74+
postContent = e.extractPostContent(op)
75+
opAuthor = e.getAuthor(op)
76+
}
77+
78+
// Replies: all posts except the OP.
79+
var replyPosts []*goquery.Selection
80+
posts.Each(func(_ int, s *goquery.Selection) {
81+
if op == nil || s.Get(0) != op.Get(0) {
82+
replyPosts = append(replyPosts, s)
83+
}
84+
})
85+
comments := e.extractComments(replyPosts)
86+
87+
contentHTML := buildContentHtml("discourse", postContent, comments)
88+
89+
author := opAuthor
90+
if author == "" && posts.Length() > 0 {
91+
author = e.getAuthor(posts.First())
92+
}
93+
94+
description := ""
95+
if op != nil {
96+
text := strings.TrimSpace(op.Find(".cooked").First().Text())
97+
runes := []rune(text)
98+
if len(runes) > 140 {
99+
runes = runes[:140]
100+
}
101+
description = whitespaceRe.ReplaceAllString(string(runes), " ")
102+
}
103+
104+
topicID, _ := doc.Find("h1[data-topic-id]").First().Attr("data-topic-id")
105+
106+
vars := map[string]string{
107+
"title": title,
108+
"author": author,
109+
"site": discourseSiteLabel(siteName),
110+
}
111+
if description != "" {
112+
vars["description"] = description
113+
}
114+
if published != "" {
115+
vars["published"] = published
116+
}
117+
118+
return &ExtractorResult{
119+
Content: contentHTML,
120+
ContentHTML: contentHTML,
121+
ExtractedContent: map[string]any{
122+
"topicId": topicID,
123+
"category": category,
124+
"tags": strings.Join(tags, ", "),
125+
},
126+
Variables: vars,
127+
}
128+
}
129+
130+
// discourseSiteLabel returns the site name when available, otherwise "Discourse".
131+
func discourseSiteLabel(siteName string) string {
132+
if strings.TrimSpace(siteName) != "" {
133+
return siteName
134+
}
135+
return "Discourse"
136+
}
137+
138+
// getTopicTitle resolves the topic title from .fancy-title, then h1[data-topic-id].
139+
// SVG icons and topic-status badges are stripped from the h1 clone.
140+
func (e *DiscourseExtractor) getTopicTitle(doc *goquery.Document) string {
141+
if fancy := doc.Find(".fancy-title").First(); fancy.Length() > 0 {
142+
return strings.TrimSpace(fancy.Text())
143+
}
144+
h1 := doc.Find("h1[data-topic-id]").First()
145+
if h1.Length() == 0 {
146+
return ""
147+
}
148+
// Clone and strip visual chrome before reading text.
149+
clone, _ := goquery.NewDocumentFromReader(strings.NewReader(
150+
func() string { s, _ := h1.Html(); return "<div>" + s + "</div>" }(),
151+
))
152+
clone.Find("svg, .topic-statuses").Remove()
153+
return strings.TrimSpace(clone.Find("div").First().Text())
154+
}
155+
156+
// getTags returns the list of tag names from Discourse tag links.
157+
func (e *DiscourseExtractor) getTags(doc *goquery.Document) []string {
158+
var tags []string
159+
doc.Find("a.discourse-tag").Each(func(_ int, a *goquery.Selection) {
160+
tag, exists := a.Attr("data-tag-name")
161+
if !exists || tag == "" {
162+
tag = strings.TrimSpace(a.Text())
163+
}
164+
if tag != "" {
165+
tags = append(tags, tag)
166+
}
167+
})
168+
return tags
169+
}
170+
171+
// getPublishedDate returns the ISO date from article:published_time meta, or "".
172+
func (e *DiscourseExtractor) getPublishedDate(doc *goquery.Document) string {
173+
content, _ := doc.Find(`meta[property="article:published_time"]`).First().Attr("content")
174+
if content == "" {
175+
return ""
176+
}
177+
t, err := time.Parse(time.RFC3339, content)
178+
if err != nil {
179+
return ""
180+
}
181+
return t.Format("2006-01-02")
182+
}
183+
184+
// getAuthor returns the username from the post's .names a[data-user-card].
185+
func (e *DiscourseExtractor) getAuthor(post *goquery.Selection) string {
186+
nameLink := post.Find(".names a[data-user-card]").First()
187+
if nameLink.Length() == 0 {
188+
return ""
189+
}
190+
if name, exists := nameLink.Attr("data-user-card"); exists && name != "" {
191+
return name
192+
}
193+
return strings.TrimSpace(nameLink.Text())
194+
}
195+
196+
// getPostDate returns the ISO date from the post's relative-date element.
197+
func (e *DiscourseExtractor) getPostDate(post *goquery.Selection) string {
198+
dateEl := post.Find(".relative-date[data-time]").First()
199+
if dateEl.Length() == 0 {
200+
return ""
201+
}
202+
rawTime, exists := dateEl.Attr("data-time")
203+
if !exists || rawTime == "" {
204+
return ""
205+
}
206+
var ms int64
207+
if _, err := fmt.Sscanf(rawTime, "%d", &ms); err != nil || ms == 0 {
208+
return ""
209+
}
210+
return time.Unix(ms/1000, 0).UTC().Format("2006-01-02")
211+
}
212+
213+
// getPostPermalink returns the absolute URL of a post's permalink anchor.
214+
func (e *DiscourseExtractor) getPostPermalink(post *goquery.Selection) string {
215+
link := post.Find("a.post-date[href]").First()
216+
if link.Length() == 0 {
217+
return ""
218+
}
219+
href, exists := link.Attr("href")
220+
if !exists || href == "" {
221+
return ""
222+
}
223+
base, err := url.Parse(e.GetURL())
224+
if err != nil {
225+
return href
226+
}
227+
ref, err := url.Parse(href)
228+
if err != nil {
229+
return href
230+
}
231+
return base.ResolveReference(ref).String()
232+
}
233+
234+
// getLikeCount returns a formatted like count string, or "" if there are none.
235+
func (e *DiscourseExtractor) getLikeCount(post *goquery.Selection) string {
236+
count := strings.TrimSpace(post.Find("button.like-count").First().Text())
237+
if count == "" {
238+
return ""
239+
}
240+
return count + " likes"
241+
}
242+
243+
// extractPostContent serializes the .cooked element after stripping visual noise.
244+
func (e *DiscourseExtractor) extractPostContent(post *goquery.Selection) string {
245+
cooked := post.Find(".cooked").First()
246+
if cooked.Length() == 0 {
247+
return ""
248+
}
249+
// Strip selection barriers and heading anchor links (visual noise).
250+
cooked.Find(".cooked-selection-barrier").Remove()
251+
cooked.Find("a.anchor").Remove()
252+
inner, _ := cooked.Html()
253+
return strings.TrimSpace(inner)
254+
}
255+
256+
// extractComments converts reply posts to a flat CommentData slice and renders
257+
// them via the shared renderCommentThread helper.
258+
func (e *DiscourseExtractor) extractComments(replyPosts []*goquery.Selection) string {
259+
if len(replyPosts) == 0 {
260+
return ""
261+
}
262+
comments := make([]CommentData, 0, len(replyPosts))
263+
for _, post := range replyPosts {
264+
author := e.getAuthor(post)
265+
content := e.extractPostContent(post)
266+
date := e.getPostDate(post)
267+
postURL := e.getPostPermalink(post)
268+
likes := e.getLikeCount(post)
269+
270+
score := ""
271+
if likes != "" {
272+
score = html.EscapeString(likes)
273+
}
274+
275+
cd := CommentData{
276+
Author: html.EscapeString(author),
277+
Content: content,
278+
Depth: 0,
279+
}
280+
if postURL != "" {
281+
cd.URL = html.EscapeString(postURL)
282+
cd.LinkText = date // show date as the link text (permalink)
283+
} else {
284+
cd.Date = date
285+
}
286+
if score != "" {
287+
cd.Extra = fmt.Sprintf(` <span class="comment-score">%s</span>`, score)
288+
}
289+
comments = append(comments, cd)
290+
}
291+
return renderCommentThread(comments)
292+
}

0 commit comments

Comments
 (0)