feat: full article fetching with trafilatura + html2text cleanup

- Add trafilatura + html2text to dependencies
- Replace custom HTMLStripper with html2text for RSS feed content
- Fetch full article text via httpx + trafilatura after each new item is stored;
  falls back to RSS-provided content if fetch/extraction fails
- Raise CONTENT_MAX_CHARS from 2000 to 50000 (TEXT column, no migration needed)
- Re-embed items with full article content once enrichment completes
- Startup backfill enriches existing items with short content (<1000 chars)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 16:33:27 -04:00
parent 0b05b03987
commit e613485474
4 changed files with 157 additions and 54 deletions
+2
View File
@@ -19,6 +19,8 @@ dependencies = [
"caldav>=1.3", "caldav>=1.3",
"icalendar>=5.0", "icalendar>=5.0",
"feedparser>=6.0", "feedparser>=6.0",
"html2text>=2024.2",
"trafilatura>=1.12",
"APScheduler>=3.10,<4.0", "APScheduler>=3.10,<4.0",
"pywebpush>=2.0", "pywebpush>=2.0",
] ]
+5
View File
@@ -266,6 +266,11 @@ def create_app() -> Quart:
await backfill_rss_item_embeddings() await backfill_rss_item_embeddings()
except Exception: except Exception:
logger.warning("RSS embedding backfill failed", exc_info=True) logger.warning("RSS embedding backfill failed", exc_info=True)
try:
from fabledassistant.services.embeddings import backfill_rss_article_content
await backfill_rss_article_content()
except Exception:
logger.warning("RSS article content backfill failed", exc_info=True)
asyncio.create_task(_delayed_backfill()) asyncio.create_task(_delayed_backfill())
@@ -297,3 +297,59 @@ async def backfill_rss_item_embeddings() -> None:
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
logger.info("RSS embedding backfill complete: %d/%d items embedded", success, len(items_to_embed)) logger.info("RSS embedding backfill complete: %d/%d items embedded", success, len(items_to_embed))
async def backfill_rss_article_content() -> None:
"""Fetch full article text for RSS items that only have short feed-provided content.
An item is considered unenriched if its content is shorter than 1000 chars —
typical of feed summaries/teasers rather than full articles.
Runs at startup after the embedding backfill.
"""
from fabledassistant.services.rss import _fetch_full_article, CONTENT_MAX_CHARS
from fabledassistant.models.rss_feed import RssFeed
SHORT_THRESHOLD = 1000
try:
async with async_session() as session:
feed_result = await session.execute(select(RssFeed.id, RssFeed.user_id))
feed_user_map = {fid: uid for fid, uid in feed_result.fetchall()}
item_result = await session.execute(
select(RssItem.id, RssItem.feed_id, RssItem.url, RssItem.title, RssItem.content)
.where(RssItem.url != "")
)
candidates = [
row for row in item_result.fetchall()
if len(row[4] or "") < SHORT_THRESHOLD
]
except Exception:
logger.warning("Article content backfill: failed to query items", exc_info=True)
return
if not candidates:
logger.info("Article content backfill: no unenriched items found")
return
logger.info("Article content backfill: enriching %d items", len(candidates))
enriched = 0
for item_id, feed_id, url, title, _ in candidates:
user_id = feed_user_map.get(feed_id)
if user_id is None:
continue
full_text = await _fetch_full_article(url)
if full_text and len(full_text) > SHORT_THRESHOLD:
try:
async with async_session() as session:
item = await session.get(RssItem, item_id)
if item:
item.content = full_text[:CONTENT_MAX_CHARS]
await session.commit()
await upsert_rss_item_embedding(item_id, user_id, title or "", full_text[:CONTENT_MAX_CHARS])
enriched += 1
except Exception:
logger.debug("Failed to store enriched content for item %d", item_id, exc_info=True)
await asyncio.sleep(0.5)
logger.info("Article content backfill complete: %d/%d items enriched", enriched, len(candidates))
+94 -54
View File
@@ -2,11 +2,11 @@
import asyncio import asyncio
import logging import logging
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from html.parser import HTMLParser
import feedparser import feedparser
import html2text
import httpx
from sqlalchemy import select, text from sqlalchemy import select, text
from fabledassistant.models import async_session from fabledassistant.models import async_session
@@ -14,60 +14,61 @@ from fabledassistant.models.rss_feed import RssFeed, RssItem
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CONTENT_MAX_CHARS = 2000 # Safety cap on stored content — effectively unlimited for typical articles
CONTENT_MAX_CHARS = 50_000
# Keep only items from the last N days to avoid unbounded growth # Keep only items from the last N days to avoid unbounded growth
ITEM_MAX_AGE_DAYS = 90 ITEM_MAX_AGE_DAYS = 90
_h2t = html2text.HTML2Text()
class _HTMLStripper(HTMLParser): _h2t.ignore_links = True
"""Minimal HTML-to-text converter using stdlib only.""" _h2t.ignore_images = True
_h2t.ignore_emphasis = False
# Tags that should produce a line break in the output _h2t.body_width = 0 # No line-wrapping
BLOCK_TAGS = frozenset({
"p", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6",
"div", "section", "article", "blockquote", "tr",
})
# Tags whose inner text should be skipped entirely
SKIP_TAGS = frozenset({"script", "style", "noscript"})
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._parts: list[str] = []
self._skip_depth = 0
def handle_starttag(self, tag: str, attrs: list) -> None:
if tag in self.SKIP_TAGS:
self._skip_depth += 1
elif tag in self.BLOCK_TAGS and self._skip_depth == 0:
self._parts.append("\n")
def handle_endtag(self, tag: str) -> None:
if tag in self.SKIP_TAGS and self._skip_depth > 0:
self._skip_depth -= 1
def handle_data(self, data: str) -> None:
if self._skip_depth == 0:
self._parts.append(data)
def get_text(self) -> str:
raw = "".join(self._parts)
# Collapse runs of whitespace/blank lines to single spaces / newlines
raw = re.sub(r"[ \t]+", " ", raw)
raw = re.sub(r"\n{3,}", "\n\n", raw)
return raw.strip()
def _strip_html(html: str) -> str: def _html_to_text(html: str) -> str:
"""Strip HTML tags and return clean plain text.""" """Convert HTML to clean plain text via html2text."""
if not html or not ("<" in html and ">" in html): if not html or "<" not in html:
return html # Already plain text — skip parsing return html
stripper = _HTMLStripper()
try: try:
stripper.feed(html) return _h2t.handle(html).strip()
return stripper.get_text()
except Exception: except Exception:
# Fallback: crude tag removal return html
return re.sub(r"<[^>]+>", " ", html).strip()
async def _fetch_full_article(url: str) -> str | None:
"""Fetch a URL and extract its main article text via trafilatura.
Returns clean plain text, or None if extraction fails or yields nothing useful.
Runs trafilatura in a thread executor since it does synchronous HTML parsing.
"""
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, headers={
"User-Agent": "Mozilla/5.0 (compatible; FabledAssistant/1.0; +https://fabledsword.com)",
}) as client:
resp = await client.get(url)
resp.raise_for_status()
raw_html = resp.text
except Exception:
logger.debug("Failed to fetch article URL %s", url)
return None
loop = asyncio.get_event_loop()
try:
import trafilatura
text = await loop.run_in_executor(
None,
lambda: trafilatura.extract(
raw_html,
include_comments=False,
include_tables=True,
favor_recall=True,
),
)
return text or None
except Exception:
logger.debug("trafilatura extraction failed for %s", url, exc_info=True)
return None
def extract_item(entry) -> dict: def extract_item(entry) -> dict:
@@ -79,7 +80,7 @@ def extract_item(entry) -> dict:
content = raw_content[0].value content = raw_content[0].value
else: else:
content = entry.get("summary", "") content = entry.get("summary", "")
content = _strip_html(content)[:CONTENT_MAX_CHARS] content = _html_to_text(content)[:CONTENT_MAX_CHARS]
pub = None pub = None
if entry.published_parsed: if entry.published_parsed:
@@ -186,16 +187,55 @@ async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
from fabledassistant.services.rss_classifier import classify_and_store from fabledassistant.services.rss_classifier import classify_and_store
asyncio.create_task(classify_and_store(unclassified_ids, feed_user_id)) asyncio.create_task(classify_and_store(unclassified_ids, feed_user_id))
# Fire-and-forget embedding for new items # Collect (id, url) for newly inserted items to enrich with full article text
if new_item_data and feed_user_id is not None: new_items_for_enrichment: list[tuple[int, str]] = []
if new_count > 0:
async with async_session() as session:
result = await session.execute(
select(RssItem.id, RssItem.url).where(
RssItem.feed_id == feed_id,
RssItem.url != "",
).order_by(RssItem.fetched_at.desc()).limit(new_count)
)
new_items_for_enrichment = list(result.fetchall())
# Fire-and-forget: fetch full article text, then re-embed with richer content
if new_items_for_enrichment and feed_user_id is not None:
from fabledassistant.services.embeddings import upsert_rss_item_embedding from fabledassistant.services.embeddings import upsert_rss_item_embedding
async def _embed_new_items() -> None: async def _enrich_and_embed() -> None:
for item_id, article_url in new_items_for_enrichment:
full_text = await _fetch_full_article(article_url)
if full_text and len(full_text) > 200:
async with async_session() as session:
item = await session.get(RssItem, item_id)
if item:
item.content = full_text[:CONTENT_MAX_CHARS]
await session.commit()
await upsert_rss_item_embedding(
item_id, feed_user_id, item.title or "", item.content
)
else:
# Enrich failed — still embed with RSS-provided content
async with async_session() as session:
item = await session.get(RssItem, item_id)
if item:
await upsert_rss_item_embedding(
item_id, feed_user_id, item.title or "", item.content or ""
)
await asyncio.sleep(0.5) # Polite pacing between article fetches
asyncio.create_task(_enrich_and_embed())
elif new_item_data and feed_user_id is not None:
# No URLs to enrich — embed with RSS content only
from fabledassistant.services.embeddings import upsert_rss_item_embedding
async def _embed_only() -> None:
for item_id, title, content in new_item_data: for item_id, title, content in new_item_data:
await upsert_rss_item_embedding(item_id, feed_user_id, title, content) await upsert_rss_item_embedding(item_id, feed_user_id, title, content)
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
asyncio.create_task(_embed_new_items()) asyncio.create_task(_embed_only())
return new_count return new_count