407 lines
13 KiB
Python
407 lines
13 KiB
Python
"""Hacker News and DEV/Forem source clients."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import html
|
|
import re
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from models import DEVArticle, HNItem
|
|
|
|
|
|
HN_BASE_URL = "https://hn.algolia.com/api/v1"
|
|
DEV_ARTICLES_URL = "https://dev.to/api/articles"
|
|
DEV_LATEST_ARTICLES_URL = "https://dev.to/api/articles/latest"
|
|
SOURCE_LOOKBACK_DAYS = 365
|
|
MAX_DEV_DETAIL_FETCHES = 8
|
|
HN_NOISE_PATTERNS = (
|
|
"ask hn: who wants to be hired",
|
|
"ask hn: who is hiring",
|
|
"ask hn: freelancer",
|
|
"ask hn: freelancers",
|
|
"who wants to be hired",
|
|
"who is hiring",
|
|
"freelancer? seeking freelancer",
|
|
)
|
|
|
|
|
|
def _since_datetime(lookback_days: int = SOURCE_LOOKBACK_DAYS) -> datetime:
|
|
return datetime.now(UTC) - timedelta(days=lookback_days)
|
|
|
|
|
|
def _parse_source_datetime(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _hn_url(hit: dict[str, Any], source_type: str) -> str:
|
|
if hit.get("url"):
|
|
return str(hit["url"])
|
|
object_id = hit.get("objectID") or hit.get("story_id")
|
|
if source_type == "comment" and hit.get("story_id"):
|
|
return f"https://news.ycombinator.com/item?id={hit['story_id']}"
|
|
return f"https://news.ycombinator.com/item?id={object_id}"
|
|
|
|
|
|
def normalize_hn_hit(hit: dict[str, Any], source_type: str) -> HNItem:
|
|
title = (
|
|
hit.get("title")
|
|
or hit.get("story_title")
|
|
or hit.get("comment_text")
|
|
or "Untitled Hacker News item"
|
|
)
|
|
text = hit.get("comment_text") or hit.get("story_text") or hit.get("text")
|
|
return HNItem(
|
|
title=html.unescape(str(title)),
|
|
url=_hn_url(hit, source_type),
|
|
author=hit.get("author"),
|
|
points=hit.get("points"),
|
|
num_comments=hit.get("num_comments"),
|
|
created_at=hit.get("created_at"),
|
|
text=html.unescape(str(text)) if text else None,
|
|
source_type=source_type,
|
|
)
|
|
|
|
|
|
async def search_hn(
|
|
queries: list[str],
|
|
limit: int = 10,
|
|
timeout: float = 20.0,
|
|
lookback_days: int = SOURCE_LOOKBACK_DAYS,
|
|
) -> list[HNItem]:
|
|
per_query = max(1, min(limit, 20))
|
|
since = _since_datetime(lookback_days)
|
|
since_unix = int(since.timestamp())
|
|
items: list[HNItem] = []
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
async def fetch(query: str, source_type: str) -> list[HNItem]:
|
|
response = await client.get(
|
|
f"{HN_BASE_URL}/search_by_date",
|
|
params={
|
|
"query": query,
|
|
"tags": source_type,
|
|
"hitsPerPage": per_query,
|
|
"numericFilters": f"created_at_i>{since_unix}",
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
return [
|
|
normalize_hn_hit(hit, source_type)
|
|
for hit in payload.get("hits", [])
|
|
]
|
|
|
|
batches = await asyncio.gather(
|
|
*[
|
|
fetch(query, source_type)
|
|
for query in queries
|
|
for source_type in ("story", "comment")
|
|
]
|
|
)
|
|
for batch in batches:
|
|
items.extend(batch)
|
|
recent = [
|
|
item
|
|
for item in _dedupe_hn_items(items)
|
|
if (created := _parse_source_datetime(item.created_at)) is None or created >= since
|
|
]
|
|
recent = [item for item in recent if not _is_noisy_hn_item(item)]
|
|
recent = _filter_hn_relevance(recent, queries)
|
|
recent.sort(key=lambda item: _parse_source_datetime(item.created_at) or since, reverse=True)
|
|
return recent[: max(limit * 2, limit)]
|
|
|
|
|
|
def _is_noisy_hn_item(item: HNItem) -> bool:
|
|
title = item.title.lower()
|
|
text = (item.text or "").lower()
|
|
haystack = f"{title} {text}"
|
|
return any(pattern in haystack for pattern in HN_NOISE_PATTERNS)
|
|
|
|
|
|
def _query_terms(queries: list[str]) -> set[str]:
|
|
stopwords = {
|
|
"and",
|
|
"the",
|
|
"for",
|
|
"with",
|
|
"from",
|
|
"that",
|
|
"this",
|
|
"tool",
|
|
"tools",
|
|
"platform",
|
|
"product",
|
|
"web",
|
|
"app",
|
|
"apps",
|
|
"api",
|
|
"real",
|
|
"user",
|
|
"users",
|
|
}
|
|
terms: set[str] = set()
|
|
for query in queries:
|
|
for token in re.findall(r"[a-z0-9]+", query.lower()):
|
|
if len(token) > 2 and token not in stopwords:
|
|
terms.add(token)
|
|
return terms
|
|
|
|
|
|
def _query_phrases(queries: list[str]) -> set[str]:
|
|
phrases: set[str] = set()
|
|
for query in queries:
|
|
normalized = " ".join(re.findall(r"[a-z0-9]+", query.lower()))
|
|
words = normalized.split()
|
|
if len(words) < 2:
|
|
continue
|
|
phrases.add(normalized)
|
|
for index in range(len(words) - 1):
|
|
pair = " ".join(words[index : index + 2])
|
|
if not any(term in pair for term in ("and", "the", "tool", "platform")):
|
|
phrases.add(pair)
|
|
return phrases
|
|
|
|
|
|
def _filter_hn_relevance(items: list[HNItem], queries: list[str]) -> list[HNItem]:
|
|
terms = _query_terms(queries)
|
|
phrases = _query_phrases(queries)
|
|
if not terms and not phrases:
|
|
return items
|
|
|
|
min_overlap = 1 if len(terms) <= 3 else 2
|
|
matched: list[HNItem] = []
|
|
for item in items:
|
|
haystack = f"{item.title} {item.text or ''}".lower()
|
|
phrase_match = any(phrase in haystack for phrase in phrases)
|
|
overlap = sum(1 for term in terms if term in haystack)
|
|
if phrase_match or overlap >= min_overlap:
|
|
matched.append(item)
|
|
return matched
|
|
|
|
|
|
def _dedupe_hn_items(items: list[HNItem]) -> list[HNItem]:
|
|
seen: set[str] = set()
|
|
unique: list[HNItem] = []
|
|
for item in items:
|
|
if item.url in seen:
|
|
continue
|
|
seen.add(item.url)
|
|
unique.append(item)
|
|
return unique
|
|
|
|
|
|
def normalize_dev_article(article: dict[str, Any]) -> DEVArticle:
|
|
user = article.get("user") or {}
|
|
raw_tags = article.get("tag_list") or article.get("tags") or []
|
|
if isinstance(raw_tags, str):
|
|
tags = [tag.strip() for tag in raw_tags.split(",") if tag.strip()]
|
|
else:
|
|
tags = [str(tag).strip() for tag in raw_tags if str(tag).strip()]
|
|
|
|
body_excerpt = _article_body_excerpt(
|
|
article.get("body_markdown")
|
|
or article.get("body_html")
|
|
or article.get("body")
|
|
)
|
|
return DEVArticle(
|
|
title=str(article.get("title") or "Untitled DEV article"),
|
|
url=str(article.get("url") or article.get("canonical_url") or ""),
|
|
id=_safe_int(article.get("id")),
|
|
path=article.get("path"),
|
|
author=user.get("username") or user.get("name"),
|
|
tags=tags,
|
|
published_at=article.get("published_at") or article.get("published_timestamp"),
|
|
positive_reactions_count=article.get("positive_reactions_count")
|
|
or article.get("public_reactions_count"),
|
|
comments_count=article.get("comments_count"),
|
|
description=article.get("description"),
|
|
body_excerpt=body_excerpt,
|
|
)
|
|
|
|
|
|
async def search_dev_articles(
|
|
queries: list[str],
|
|
tags: list[str],
|
|
limit: int = 10,
|
|
api_key: str | None = None,
|
|
timeout: float = 20.0,
|
|
lookback_days: int = SOURCE_LOOKBACK_DAYS,
|
|
) -> list[DEVArticle]:
|
|
headers = {"User-Agent": "EngineeringContentAgent/0.1"}
|
|
if api_key:
|
|
headers["api-key"] = api_key
|
|
|
|
per_query = max(1, min(limit, 30))
|
|
since = _since_datetime(lookback_days)
|
|
articles: list[DEVArticle] = []
|
|
async with httpx.AsyncClient(timeout=timeout, headers=headers) as client:
|
|
async def fetch_tag(tag: str) -> list[DEVArticle]:
|
|
try:
|
|
response = await client.get(
|
|
DEV_ARTICLES_URL,
|
|
params={
|
|
"tag": tag,
|
|
"top": lookback_days,
|
|
"per_page": per_query,
|
|
"page": 1,
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as exc:
|
|
if exc.response.status_code in {404, 429}:
|
|
return []
|
|
raise
|
|
return [normalize_dev_article(item) for item in response.json()]
|
|
|
|
if tags:
|
|
batches = await asyncio.gather(
|
|
*[fetch_tag(tag) for tag in tags[:6]]
|
|
)
|
|
for batch in batches:
|
|
articles.extend(batch)
|
|
else:
|
|
response = await client.get(
|
|
DEV_LATEST_ARTICLES_URL,
|
|
params={"per_page": per_query, "page": 1},
|
|
)
|
|
response.raise_for_status()
|
|
articles.extend(normalize_dev_article(item) for item in response.json())
|
|
|
|
filtered = filter_dev_articles(articles, queries, tags)
|
|
recent = [
|
|
article
|
|
for article in _dedupe_articles(filtered)
|
|
if (published := _parse_source_datetime(article.published_at)) is None
|
|
or published >= since
|
|
]
|
|
recent.sort(
|
|
key=lambda article: _parse_source_datetime(article.published_at) or since,
|
|
reverse=True,
|
|
)
|
|
enriched = await _enrich_dev_article_details(
|
|
client,
|
|
recent[: max(limit * 2, limit)],
|
|
max_details=min(MAX_DEV_DETAIL_FETCHES, max(limit, 1)),
|
|
)
|
|
return enriched[: max(limit * 2, limit)]
|
|
|
|
|
|
def filter_dev_articles(
|
|
articles: list[DEVArticle],
|
|
queries: list[str],
|
|
tags: list[str],
|
|
) -> list[DEVArticle]:
|
|
needles = _query_terms([*queries, *tags])
|
|
phrases = _query_phrases(queries)
|
|
if not needles and not phrases:
|
|
return articles
|
|
|
|
matched: list[DEVArticle] = []
|
|
for article in articles:
|
|
haystack = " ".join(
|
|
[
|
|
article.title,
|
|
article.description or "",
|
|
article.body_excerpt or "",
|
|
" ".join(article.tags),
|
|
]
|
|
).lower()
|
|
phrase_match = any(phrase in haystack for phrase in phrases)
|
|
overlap = sum(1 for needle in needles if needle in haystack)
|
|
if phrase_match or overlap >= 2:
|
|
matched.append(article)
|
|
return matched
|
|
|
|
|
|
def _dedupe_articles(articles: list[DEVArticle]) -> list[DEVArticle]:
|
|
seen: set[str] = set()
|
|
unique: list[DEVArticle] = []
|
|
for article in articles:
|
|
key = article.url or article.title
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
unique.append(article)
|
|
return unique
|
|
|
|
|
|
async def _enrich_dev_article_details(
|
|
client: httpx.AsyncClient,
|
|
articles: list[DEVArticle],
|
|
max_details: int = MAX_DEV_DETAIL_FETCHES,
|
|
) -> list[DEVArticle]:
|
|
candidates = [
|
|
article
|
|
for article in articles
|
|
if article.id is not None and not article.body_excerpt
|
|
][:max_details]
|
|
if not candidates:
|
|
return articles
|
|
|
|
async def fetch_detail(article: DEVArticle) -> DEVArticle:
|
|
try:
|
|
response = await client.get(f"{DEV_ARTICLES_URL}/{article.id}")
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
return article
|
|
detailed = normalize_dev_article(response.json())
|
|
return DEVArticle(
|
|
title=detailed.title or article.title,
|
|
url=detailed.url or article.url,
|
|
id=detailed.id or article.id,
|
|
path=detailed.path or article.path,
|
|
author=detailed.author or article.author,
|
|
tags=detailed.tags or article.tags,
|
|
published_at=detailed.published_at or article.published_at,
|
|
positive_reactions_count=(
|
|
detailed.positive_reactions_count
|
|
if detailed.positive_reactions_count is not None
|
|
else article.positive_reactions_count
|
|
),
|
|
comments_count=(
|
|
detailed.comments_count
|
|
if detailed.comments_count is not None
|
|
else article.comments_count
|
|
),
|
|
description=detailed.description or article.description,
|
|
body_excerpt=detailed.body_excerpt or article.body_excerpt,
|
|
)
|
|
|
|
enriched_by_url = {
|
|
article.url: article for article in await asyncio.gather(
|
|
*(fetch_detail(article) for article in candidates)
|
|
)
|
|
}
|
|
return [enriched_by_url.get(article.url, article) for article in articles]
|
|
|
|
|
|
def _safe_int(value: object) -> int | None:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _article_body_excerpt(value: object, limit: int = 700) -> str | None:
|
|
if not value:
|
|
return None
|
|
text = html.unescape(str(value))
|
|
text = re.sub(r"<[^>]+>", " ", text)
|
|
text = re.sub(r"```.*?```", " ", text, flags=re.DOTALL)
|
|
text = re.sub(r"`([^`]+)`", r"\1", text)
|
|
text = re.sub(r"!\[[^\]]*\]\([^)]+\)", " ", text)
|
|
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
|
text = re.sub(r"#+\s*", " ", text)
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
if not text:
|
|
return None
|
|
return text[:limit].rstrip()
|