Download and store full article text at ingest #32

Manually merged
marvin8 merged 6 commits from feat/issue-23-full-text into main 2026-09-12 23:28:06 +00:00
Collaborator

Full text is fetched when an article is first ingested and stored as markdown, so the reader shows the whole story instead of the feed teaser.

  • Extraction: trafilatura main-content extraction with native markdown output, images kept; non-HTML content types skipped; article URLs pass the same SSRF guard as feeds (redirects re-validated per hop); per-article failures leave the teaser in place and never fail a refresh.
  • Schema: alembic migration adds articles.full_text + full_text_fetched_at (NULL = teaser-only).
  • LLM steps: the aggregation prompt and the combined-summary teasers both prefer stored full text (prompt excerpts capped at 1000 chars/article — _EXCERPT_MAX, tunable).
  • Display: the article page renders stored markdown through mistune with nh3 sanitization (raw HTML in stored text cannot inject scripts); teaser fallback unchanged.

Design decisions per the issue discussion (ingest-time fetch chosen over lazy/backfill).

Closes #23

Full text is fetched when an article is first ingested and stored as markdown, so the reader shows the whole story instead of the feed teaser. - **Extraction**: trafilatura main-content extraction with native markdown output, images kept; non-HTML content types skipped; article URLs pass the same SSRF guard as feeds (redirects re-validated per hop); per-article failures leave the teaser in place and never fail a refresh. - **Schema**: alembic migration adds `articles.full_text` + `full_text_fetched_at` (NULL = teaser-only). - **LLM steps**: the aggregation prompt and the combined-summary teasers both prefer stored full text (prompt excerpts capped at 1000 chars/article — `_EXCERPT_MAX`, tunable). - **Display**: the article page renders stored markdown through mistune with nh3 sanitization (raw HTML in stored text cannot inject scripts); teaser fallback unchanged. Design decisions per the issue discussion (ingest-time fetch chosen over lazy/backfill). Closes #23
Render stored full text on the article page
All checks were successful
/ gitleaks (pull_request) Successful in 22s
/ checks (pull_request) Successful in 1m38s
/ pr-review (pull_request) Successful in 3m3s
5cafd6483e
forgejo-actions left a comment

WuMing

Found 2 issue(s). See inline comments below.

## WuMing Found **2** issue(s). See inline comments below.
@ -583,0 +639,4 @@
entry = _build_article_entry(1, "T", long_text, "https://example.com/a")
assert long_text[:_EXCERPT_MAX] in entry

tests [MEDIUM]

The excerpt test imports the limit from the code under test (_EXCERPT_MAX) and asserts long_text[:_EXCERPT_MAX] in entry, so the assertion always matches whatever value the constant has — reverting the cap to the old 300 characters would still pass. Only the "…" in entry check pins anything. Assert against the literal expected cap (e.g. assert len(_build_article_entry(...).split('\n')[-1]) <= 1000 + 1 or check that text beyond 1000 chars is absent) so a regression in the cap value is actually caught.

**tests** [MEDIUM] The excerpt test imports the limit from the code under test (`_EXCERPT_MAX`) and asserts `long_text[:_EXCERPT_MAX] in entry`, so the assertion always matches whatever value the constant has — reverting the cap to the old 300 characters would still pass. Only the `"…" in entry` check pins anything. Assert against the literal expected cap (e.g. `assert len(_build_article_entry(...).split('\n')[-1]) <= 1000 + 1` or check that text beyond 1000 chars is absent) so a regression in the cap value is actually caught. <!-- wuming:sha256:11c69e2b5311c657d5639fc9d4f3297a27d35af9dcd6a65d15ad3e0e6eb27869 -->
@ -721,0 +754,4 @@
response = client.get(f"/article/{article.id}")
assert b"<h1>" in response.data

tests [LOW]

assert b"<h1>" in response.data is likely satisfied by the article page template's own title heading, so it does not verify that the markdown heading in the stored full text was rendered. Assert on the rendered heading text instead (e.g. b"<h1>Wind project approved</h1>" in response.data) to make the assertion specific to the full-text rendering path.

**tests** [LOW] `assert b"<h1>" in response.data` is likely satisfied by the article page template's own title heading, so it does not verify that the markdown heading in the stored full text was rendered. Assert on the rendered heading text instead (e.g. `b"<h1>Wind project approved</h1>"` in `response.data`) to make the assertion specific to the full-text rendering path. <!-- wuming:sha256:559cb41e8bda4c48bc965dbf91d3d71166a70bcbd35bdba4abadee374ad56170 -->
Tighten full-text test assertions
All checks were successful
/ gitleaks (pull_request) Successful in 18s
/ checks (pull_request) Successful in 2m17s
/ pr-review (pull_request) Successful in 2m42s
25e1e41416
forgejo-actions left a comment

WuMing

Found 2 issue(s). See inline comments below.

## WuMing Found **2** issue(s). See inline comments below.
@ -0,0 +60,4 @@
_validate_feed_url(url)
response = _fetch_feed_response(url)
except FetchError as exc:
logger.warning(f"Full-text fetch skipped for {url}: {exc}")

security [LOW]

A09: Logs the untrusted article URL directly. URLs can contain query-string credentials/tokens or CRLF sequences, leading to sensitive data disclosure and log injection. Redact query parameters and strip control characters before logging.

**security** [LOW] A09: Logs the untrusted article URL directly. URLs can contain query-string credentials/tokens or CRLF sequences, leading to sensitive data disclosure and log injection. Redact query parameters and strip control characters before logging. <!-- wuming:sha256:4d185c2ed29f18194eb8509faeab31ed027fc583feea7dde7bafaf694a823759 -->
@ -0,0 +89,4 @@
try:
full_text = fetch_full_text(article.url)
except Exception:
logger.error(f"Unexpected full-text fetch error for {article.url}", exc_info=True)

security [LOW]

A09: Logs the untrusted article URL directly. URLs can contain query-string credentials/tokens or CRLF sequences, leading to sensitive data disclosure and log injection. Redact query parameters and strip control characters before logging.

**security** [LOW] A09: Logs the untrusted article URL directly. URLs can contain query-string credentials/tokens or CRLF sequences, leading to sensitive data disclosure and log injection. Redact query parameters and strip control characters before logging. <!-- wuming:sha256:6808729dc658aa67097109d5ec9956b16ee47f7d990f839994689e9e015fa6bb -->
🐛 Rewind full-text columns in the legacy-migration fixture
All checks were successful
/ gitleaks (pull_request) Successful in 20s
/ checks (pull_request) Successful in 2m0s
/ pr-review (pull_request) Successful in 3m1s
8af76f93f1
Author
Collaborator

Fixed — both test-strength findings

tests/test_aggregator.py · tests/test_web.py

The excerpt test imports the limit from the code under test (_EXCERPT_MAX)… / assert b"<h1>" in response.data is likely satisfied by the article page template's own title heading…

Both fair. The excerpt test now hardcodes the 1000-char boundary (with a comment tying it to _EXCERPT_MAX so retunes update it deliberately), asserts the ellipsis, and asserts the full uncapped text is absent. The markdown-heading assertion now checks <h1>Wind project approved</h1> — the rendered heading text, which the template's own title cannot satisfy. Pushed as 25e1e41; the first push also surfaced a legacy-migration fixture that needed the new columns rewound, fixed in 8af76f9.

### ✅ Fixed — both test-strength findings [`tests/test_aggregator.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/32#issuecomment-2937) · [`tests/test_web.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/32#issuecomment-2938) > The excerpt test imports the limit from the code under test (`_EXCERPT_MAX`)… / `assert b"<h1>" in response.data` is likely satisfied by the article page template's own title heading… Both fair. The excerpt test now hardcodes the 1000-char boundary (with a comment tying it to `_EXCERPT_MAX` so retunes update it deliberately), asserts the ellipsis, and asserts the full uncapped text is absent. The markdown-heading assertion now checks `<h1>Wind project approved</h1>` — the rendered heading text, which the template's own title cannot satisfy. Pushed as `25e1e41`; the first push also surfaced a legacy-migration fixture that needed the new columns rewound, fixed in `8af76f9`.
forgejo-actions left a comment

WuMing

Found 2 issue(s). See inline comments below.

## WuMing Found **2** issue(s). See inline comments below.
@ -25,6 +25,10 @@ dependencies = [
"feedparser~=6.0.14",
"flask~=3.1.3",
"httpx2~=2.12.0",
"markupsafe~=3.0.3",

config [MEDIUM]

markupsafe~=3.0.3 is added as a direct runtime dependency, but MarkupSafe is already pulled in transitively by Flask/Jinja2 (both declared just above), and nothing in the described feature (trafilatura extraction, mistune rendering, nh3 sanitization) needs it directly. More importantly, pylock.toml in this diff adds/updates entries for every other new dependency (mistune, nh3, trafilatura and all of trafilatura's transitive deps) but contains no MarkupSafe entry, so if the locked MarkupSafe version is < 3.0.3 the lockfile no longer satisfies pyproject and reproducible installs (uv sync --locked / CI) will fail. Either drop this line or regenerate pylock.toml so the resolved MarkupSafe version satisfies ~=3.0.3.

**config** [MEDIUM] `markupsafe~=3.0.3` is added as a direct runtime dependency, but MarkupSafe is already pulled in transitively by Flask/Jinja2 (both declared just above), and nothing in the described feature (trafilatura extraction, mistune rendering, nh3 sanitization) needs it directly. More importantly, pylock.toml in this diff adds/updates entries for every other new dependency (mistune, nh3, trafilatura and all of trafilatura's transitive deps) but contains no MarkupSafe entry, so if the locked MarkupSafe version is < 3.0.3 the lockfile no longer satisfies pyproject and reproducible installs (`uv sync --locked` / CI) will fail. Either drop this line or regenerate pylock.toml so the resolved MarkupSafe version satisfies `~=3.0.3`. <!-- wuming:sha256:126e366b50dfabb41de47b1e5acbeab1d2286abdae376104b03b2bfd8a77a57d -->
marvin8 marked this conversation as resolved
@ -0,0 +58,4 @@
"""
try:
_validate_feed_url(url)
response = _fetch_feed_response(url)

security [MEDIUM]

Article URLs come from untrusted feed entries and are passed to an HTTP client after only _validate_feed_url. Per the PR this is a deny-list of private/reserved addresses rather than an allow-list, so SSRF is still possible via DNS rebinding, alternate IP encodings, or a missing per-hop redirect revalidation. Use a strict allow-list of schemes/hosts and pin the resolved IP for the HTTP client.

**security** [MEDIUM] Article URLs come from untrusted feed entries and are passed to an HTTP client after only _validate_feed_url. Per the PR this is a deny-list of private/reserved addresses rather than an allow-list, so SSRF is still possible via DNS rebinding, alternate IP encodings, or a missing per-hop redirect revalidation. Use a strict allow-list of schemes/hosts and pin the resolved IP for the HTTP client. <!-- wuming:sha256:e13841a00238eee29161a178508cb7114e7e126be2a5f424d95ddaa01d056610 -->
marvin8 approved these changes 2026-09-12 23:27:04 +00:00
marvin8 manually merged commit dbf50468d1 into main 2026-09-12 23:28:06 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
marvin8/yunjin!32
No description provided.