Skip to content

Commit d004c67

Browse files
Story 2451: AI-Assisted Description for Link Posts type (boostorg#2477)
1 parent dccd443 commit d004c67

11 files changed

Lines changed: 810 additions & 31 deletions

File tree

config/urls.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@
5454
CommitEmailResendView,
5555
)
5656
from news.feeds import AtomNewsFeed, RSSNewsFeed
57-
from news.views import V3AllTypesCreateView, generate_description
57+
from news.views import (
58+
V3AllTypesCreateView,
59+
generate_description,
60+
generate_link_description,
61+
)
5862
from users.views import (
5963
CurrentUserAPIView,
6064
CurrentUserProfileView,
@@ -279,6 +283,11 @@
279283
generate_description,
280284
name="v3-news-generate-description",
281285
),
286+
path(
287+
"v3/news/generate-link-description/",
288+
generate_link_description,
289+
name="v3-news-generate-link-description",
290+
),
282291
path(
283292
"people/detail/",
284293
TemplateView.as_view(template_name="boost/people_detail.html"),

news/forms.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ class Meta(NewsForm.Meta):
5757
fields = ["title", "publish_at", "content", "summary", "image"]
5858

5959

60+
class V3LinkForm(LinkForm):
61+
class Meta(LinkForm.Meta):
62+
fields = ["title", "publish_at", "external_url", "summary", "image"]
63+
64+
6065
class PollForm(EntryForm):
6166
class Meta:
6267
model = Poll

news/helpers.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,111 @@
1+
import ipaddress
2+
import socket
3+
from urllib.parse import urljoin, urlparse
4+
5+
import requests
16
from bs4 import BeautifulSoup
7+
import trafilatura
8+
9+
# Hostnames that should never be fetched server-side, regardless of resolution.
10+
_BLOCKED_HOSTNAMES = {"localhost"}
11+
12+
# Hard cap on a fetched body. Generous for an article, but bounds the memory and
13+
# worker time a single (possibly hostile) response can consume.
14+
MAX_FETCH_BYTES = 2_000_000 # 2 MB
15+
16+
17+
class UnsafeURLError(Exception):
18+
"""Raised when a URL (or a redirect target) points at a non-public host."""
19+
20+
21+
def _is_public_ip(ip_str: str) -> bool:
22+
"""True only for globally routable addresses. ``is_global`` rejects
23+
loopback, private, link-local, multicast, reserved and unspecified ranges
24+
(e.g. 127.0.0.1, 10.x, 169.254.169.254, ::1) in one check."""
25+
try:
26+
return ipaddress.ip_address(ip_str).is_global
27+
except ValueError:
28+
return False
29+
30+
31+
def _url_host_is_safe(url: str) -> bool:
32+
"""True if ``url`` is an http(s) URL whose host resolves only to public IPs.
33+
34+
Blocks SSRF to internal/loopback/link-local targets. Resolves the hostname
35+
and requires *every* returned address to be public, so a name that maps to
36+
a private IP is rejected. IP-literal hosts are checked directly.
37+
"""
38+
try:
39+
parsed = urlparse(url)
40+
except ValueError:
41+
return False
42+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
43+
return False
44+
host = parsed.hostname
45+
if host.lower() in _BLOCKED_HOSTNAMES:
46+
return False
47+
# IP literal: check it directly without a DNS lookup.
48+
try:
49+
ipaddress.ip_address(host)
50+
return _is_public_ip(host)
51+
except ValueError:
52+
pass
53+
# Hostname: resolve and require ALL addresses to be public.
54+
try:
55+
infos = socket.getaddrinfo(host, parsed.port, proto=socket.IPPROTO_TCP)
56+
except socket.gaierror:
57+
return False
58+
addresses = {info[4][0] for info in infos}
59+
return bool(addresses) and all(_is_public_ip(addr) for addr in addresses)
60+
61+
62+
def safe_get(
63+
url: str, *, timeout: float = 10, max_redirects: int = 5
64+
) -> requests.Response:
65+
"""GET ``url`` with SSRF protection.
66+
67+
Redirects are followed manually so each hop's host is re-validated — a
68+
public URL can otherwise 302 to an internal one. The response body is capped
69+
at ``MAX_FETCH_BYTES`` (checked against ``Content-Length`` and again while
70+
streaming, since the header can be absent or wrong). Raises ``UnsafeURLError``
71+
if the URL or any redirect target isn't a public http(s) host, or the body
72+
exceeds the cap; network failures propagate as ``requests.RequestException``.
73+
74+
Residual gap: DNS rebinding (host resolves public at check time, private at
75+
connect time) is not closed — that needs pinning the validated IP into the
76+
connection. TODO: the calling endpoint is not yet login-gated or
77+
rate-limited; close this gap (or add those controls) before relying on it.
78+
"""
79+
for _ in range(max_redirects + 1):
80+
if not _url_host_is_safe(url):
81+
raise UnsafeURLError(url)
82+
resp = requests.get(url, timeout=timeout, allow_redirects=False, stream=True)
83+
if resp.is_redirect:
84+
location = resp.headers.get("Location")
85+
resp.close()
86+
if not location:
87+
resp._content = b""
88+
return resp
89+
url = urljoin(url, location)
90+
continue
91+
# Reject early if the server declares an oversized body, then enforce the
92+
# cap while streaming in case Content-Length is missing or understated.
93+
declared = resp.headers.get("Content-Length")
94+
if declared and declared.isdigit() and int(declared) > MAX_FETCH_BYTES:
95+
resp.close()
96+
raise UnsafeURLError(f"response too large: {url}")
97+
chunks, total = [], 0
98+
for chunk in resp.iter_content(8192):
99+
total += len(chunk)
100+
if total > MAX_FETCH_BYTES:
101+
resp.close()
102+
raise UnsafeURLError(f"response too large: {url}")
103+
chunks.append(chunk)
104+
# Populate the body so callers can use resp.text/.content normally despite
105+
# stream=True (which otherwise defers — and would bypass — the read).
106+
resp._content = b"".join(chunks)
107+
return resp
108+
raise UnsafeURLError(f"too many redirects: {url}")
2109

3110

4111
def extract_content(html: str) -> str:
@@ -11,3 +118,34 @@ def extract_content(html: str) -> str:
11118
# drop blank lines
12119
minimized = [line for line in lines if line]
13120
return "\n".join(minimized)
121+
122+
123+
def extract_article(html: str, url: str | None = None) -> tuple[str, str]:
124+
"""Extract ``(title, body)`` of the main article from an arbitrary web page.
125+
126+
trafilatura isolates the main content and strips boilerplate (navigation,
127+
footers, comments, ads). That keeps the summarizer focused — and cheaper —
128+
versus feeding it the whole page. When trafilatura can't isolate an article
129+
(unusual template, very thin page) the body falls back to the naive
130+
visible-text dump in ``extract_content`` so server-rendered HTML still
131+
yields something.
132+
133+
Returns ``("", "")`` only when even the fallback is empty — callers should
134+
treat an empty body as "couldn't read the page" and degrade gracefully
135+
(skip auto-summarization / surface an inline error) rather than summarizing
136+
junk. Note: pages rendered client-side (SPAs), paywalled, or behind anti-bot
137+
blocks won't yield text here regardless of which extractor runs.
138+
"""
139+
body = (
140+
trafilatura.extract(html, url=url, include_comments=False, include_tables=False)
141+
or ""
142+
).strip()
143+
if not body:
144+
body = extract_content(html)
145+
146+
title = ""
147+
metadata = trafilatura.extract_metadata(html, default_url=url)
148+
if metadata and metadata.title:
149+
title = metadata.title.strip()
150+
151+
return title, body

news/tasks.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from config.celery import app
77
from config.settings import OPENROUTER_API_KEY, OPENROUTER_URL, SUMMARIZATION_MODEL
88
from news.constants import CONTENT_SUMMARIZATION_THRESHOLD
9-
from news.helpers import extract_content
9+
from news.helpers import UnsafeURLError, extract_article, safe_get
1010
from news.utils import set_video_thumbnail
1111

1212
logger = structlog.get_logger(__name__)
@@ -123,9 +123,15 @@ def summarize_content(
123123

124124

125125
@app.task
126-
def save_entry_summary_value(summary: str, pk: int):
126+
def save_entry_summary_value(summary: str | None, pk: int):
127127
from news.models import Entry
128128

129+
# generate_summary returns None/"" on malformed or empty model output; saving
130+
# that would clobber an existing Entry.summary, so treat it as "do not save".
131+
if not summary:
132+
logger.warning(f"Skipping summary save for {pk=}: empty/malformed model output")
133+
return
134+
129135
entry = Entry.objects.get(pk=pk)
130136
entry.summary = summary
131137
entry.save()
@@ -172,16 +178,27 @@ def set_summary_for_link_entry(pk: int):
172178
entry = Entry.objects.get(pk=pk)
173179
try:
174180
logger.info(f"Fetching content from {entry.external_url=} for entry.{pk=}")
175-
response = requests.get(entry.external_url, timeout=10)
181+
response = safe_get(entry.external_url, timeout=10)
176182
response.raise_for_status()
177183
markup = response.text
178184
logger.debug(f"Fetched {len(markup)=} for entry.{pk=}...")
179-
content = extract_content(markup)
180-
logger.info(f"extracted content from {entry.external_url=}, {markup[:100]=}")
185+
_title, content = extract_article(markup, url=entry.external_url)
186+
logger.info(
187+
f"extracted content from {entry.external_url=}, extracted_chars={len(content)}"
188+
)
189+
except UnsafeURLError:
190+
logger.warning(f"Refusing to fetch unsafe {entry.external_url=} for {pk=}")
191+
return
181192
except requests.RequestException as e:
182193
logger.error(f"Error fetching content from {entry.external_url=}: {e=}")
183194
return
184195

196+
if not content:
197+
logger.warning(
198+
f"No content extracted from {entry.external_url=} for {pk=}, skipping."
199+
)
200+
return
201+
185202
logger.info(f"dispatching summarize task for {pk=} with {content[:40]=}...")
186203
summarize_content.apply_async(
187204
(content, entry.title, SUMMARIZATION_MODEL), link=save_entry_summary_value.s(pk)

news/tests/test_forms.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
NewsForm,
1111
PollForm,
1212
V3BlogPostForm,
13+
V3LinkForm,
1314
V3NewsForm,
1415
VideoForm,
1516
)
@@ -179,6 +180,20 @@ def test_v3_news_form_includes_summary():
179180
]
180181

181182

183+
def test_v3_link_form_includes_summary():
184+
# The v3 create page persists the AI-assisted link description into the
185+
# model's `summary` field; the v2 LinkForm must not expose it.
186+
form = V3LinkForm()
187+
assert isinstance(form, LinkForm)
188+
assert sorted(form.fields.keys()) == [
189+
"external_url",
190+
"image",
191+
"publish_at",
192+
"summary",
193+
"title",
194+
]
195+
196+
182197
def test_poll_form():
183198
form = PollForm()
184199
assert isinstance(form, EntryForm)

0 commit comments

Comments
 (0)