Skip to content

Commit 3b44f5c

Browse files
Merge pull request #12 from OpenElementsLabs/feat/006-content-extractor
Content extractor — jsoup content + metadata extraction
2 parents 0b0b2bc + adaae99 commit 3b44f5c

6 files changed

Lines changed: 660 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ extraction, Meilisearch indexing with scheduled refresh, and four MCP tools
4343
│ ├── UrlMatcher.java # Ant-glob include/exclude matching against URL paths
4444
│ ├── ContentLocaleResolver.java # path-prefix locale rule (/de -> German, else English)
4545
│ ├── DiscoveredItem.java # a discovered URL + lastmod change marker
46-
│ └── SitemapCrawler.java # sitemap/index discovery + bounded fallback crawl
46+
│ ├── SitemapCrawler.java # sitemap/index discovery + bounded fallback crawl
47+
│ ├── ContentExtractor.java # jsoup body + metadata extraction
48+
│ └── ExtractedContent.java # extractor output (body + metadata)
4749
├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config
4850
├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup)
4951
└── docs/
@@ -80,7 +82,11 @@ returns `DiscoveredItem`s filtered by `UrlMatcher`; it is fault-tolerant per sit
8082
(the fetch stage) performs polite HTTP GETs — bot `User-Agent`, conditional `If-None-Match`/
8183
`If-Modified-Since` (→ `304` handling), a `max-body-bytes` cap, per-host rate limiting
8284
(`HostRateLimiter`), and retry-with-backoff on transient failures — returning a classified
83-
`FetchResult` (`OK`/`NOT_MODIFIED`/`NOT_FOUND`/`ERROR`).
85+
`FetchResult` (`OK`/`NOT_MODIFIED`/`NOT_FOUND`/`ERROR`). `ContentExtractor` (the extract stage) turns
86+
fetched HTML into an `ExtractedContent` via jsoup: it selects the main container by the source's
87+
`contentSelector` (with a readability fallback), always strips scripts/styles, applies
88+
`contentExclude`, whitespace-normalizes the body, and reads metadata (title/excerpt/date/author/
89+
categories/preview image/locale) from OpenGraph/Article `<meta>`, JSON-LD, and `<time>`.
8490

8591
> **Key gotcha:** the library ships no Spring Boot auto-configuration and couples MCP to a JPA
8692
> datasource, so `@Import({ McpConfiguration, SearchConfig })` alone does **not** boot. See
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Implementation Steps: Content Extractor
2+
3+
## Step 1: `ExtractedContent` record
4+
5+
- [x] Record with `title`, `excerpt`, `body`, `author`, `categories`, `publishedDate`, `previewImage`, `locale`; `categories` normalized to non-null
6+
7+
**Related behaviors:** all (return shape)
8+
9+
---
10+
11+
## Step 2: `ContentExtractor` — body extraction
12+
13+
- [x] `@Component` taking `ContentLocaleResolver` + `ObjectMapper`
14+
- [x] Read metadata first, then strip `script`/`style`/`noscript`/`template` + comments (so JSON-LD survives)
15+
- [x] Container selection: `contentSelector` comma list (first non-empty match), `body` = whole document, readability fallback (semantic tag, else largest div/section) when nothing matches
16+
- [x] Remove `contentExclude` selectors from the container
17+
- [x] Whitespace-normalized, block-separated body (`\n\n` between paragraphs); fallback to collapsed container text
18+
19+
**Related behaviors:** selector container; comma fallback; body selector; scripts/styles removed; contentExclude; whitespace normalized; readability fallback; empty container
20+
21+
---
22+
23+
## Step 3: `ContentExtractor` — metadata extraction
24+
25+
- [x] title: `og:title``<title>` → first `<h1>`
26+
- [x] excerpt: `description``og:description`
27+
- [x] previewImage: `og:image`
28+
- [x] publishedDate: `article:published_time``<time datetime>` → JSON-LD `datePublished`
29+
- [x] author: `author`/`article:author` meta → JSON-LD `author.name`
30+
- [x] categories: `article:tag` metas → JSON-LD `keywords`
31+
- [x] locale: from `ContentLocaleResolver` (path rule)
32+
- [x] JSON-LD parsed via Jackson, following arrays and `@graph`
33+
- [x] Graceful degradation to null when fields are absent
34+
35+
**Related behaviors:** OpenGraph metadata; JSON-LD date/author; article tags; locale; metadata independent of selector; missing metadata
36+
37+
---
38+
39+
## Step 4: Tests
40+
41+
- [x] `ContentExtractorTest` (14 cases) — body selection/cleaning + metadata + edge cases
42+
43+
**Acceptance criteria:**
44+
- [x] All tests pass (`mvn test`); build green
45+
46+
---
47+
48+
## Behavior Coverage
49+
50+
| Scenario | Layer | Covered in Step |
51+
|----------|-------|-----------------|
52+
| Selector picks the article container | Backend | Step 4 (`selectorPicksArticleContainer`) |
53+
| Comma-separated fallback list, first match wins | Backend | Step 4 (`commaSeparatedFallbackFirstMatchWins`) |
54+
| body selector takes the whole document | Backend | Step 4 (`bodySelectorTakesWholeDocument`) |
55+
| Scripts and styles are always removed | Backend | Step 4 (`scriptsAndStylesAreAlwaysRemoved`) |
56+
| contentExclude removes boilerplate | Backend | Step 4 (`contentExcludeRemovesBoilerplate`) |
57+
| Whitespace is normalized | Backend | Step 4 (`whitespaceIsNormalized`) |
58+
| Readability fallback when no selector matches | Backend | Step 4 (`readabilityFallbackWhenNoSelectorMatches`) |
59+
| OpenGraph metadata is read | Backend | Step 4 (`openGraphMetadataIsRead`) |
60+
| JSON-LD fallback for date/author | Backend | Step 4 (`jsonLdFallbackForDateAndAuthor`) |
61+
| Categories from article tags | Backend | Step 4 (`categoriesFromArticleTags`) |
62+
| Locale derived from path | Backend | Step 4 (`localeDerivedFromPath`) |
63+
| Metadata read regardless of content selector | Backend | Step 4 (`metadataReadRegardlessOfContentSelector`) |
64+
| Missing metadata degrades gracefully | Backend | Step 4 (`missingMetadataDegradesGracefully`) |
65+
| Empty content container | Backend | Step 4 (`emptyContentContainerFallsBack`) |
66+
67+
All scenarios are backend; there is no frontend in this spec.
68+
69+
## Notes
70+
71+
- Excerpt open question: kept `null` when no `description`/`og:description` (no body-snippet fallback) — the behavior allows null.
72+
- Markdown vs plaintext: plaintext body kept (no `flexmark` dependency added).

docs/specs/INDEX.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ roadmap step, to be implemented sequentially (each builds on the previous).
1111
| 003 | 003-content-document-index | Content document & index | backend, search, data-model | `ContentDocument` record + Meilisearch `IndexSettings` bean | #5 | done |
1212
| 004 | 004-sitemap-crawler | Sitemap crawler | backend, crawler | `SitemapCrawler` — sitemap discovery of URLs + lastmod | #7 | done |
1313
| 005 | 005-page-fetcher | Page fetcher | backend, crawler | `PageFetcher` — robust HTTP fetch (ETag/IMS, rate limit, retry) | #9 | done |
14-
| 006 | 006-content-extractor | Content extractor | backend, crawler | `ContentExtractor` — jsoup content + metadata extraction | | open |
14+
| 006 | 006-content-extractor | Content extractor | backend, crawler | `ContentExtractor` — jsoup content + metadata extraction | #11 | done |
1515
| 007 | 007-source-strategy | Source strategy | backend, architecture | `ContentSourceStrategy` interface + `WebsiteSourceStrategy` || open |
1616
| 008 | 008-content-indexer | Content indexer | backend, search, crawler | `ContentIndexer` — orchestration, diff, upsert/delete || open |
1717
| 009 | 009-content-bootstrap-step | Content bootstrap step | backend, search | `ContentBootstrapStep` — initial reindex via `SearchIndexBootstrapStep` || open |

0 commit comments

Comments
 (0)