"""Забор источников из реестра sources.py. Один вход: fetch(source, limit) -> list[Item]. Адаптер выбирается по source.adapter. Всё без ключей, кроме опционального GITHUB_TOKEN (поднимает лимит api.github.com). """ import html import json import re import urllib.parse import xml.etree.ElementTree as ET from bs4 import BeautifulSoup from . import config, net from .model import Item UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/141.0.0.0 Safari/537.36", "Accept-Language": "en,ru;q=0.8"} def _txt(x): if x is None: return "" return html.unescape(re.sub(r"<[^>]+>", " ", str(x))).strip() def _item(src, kind, url, title, text="", author="", published="", extra=None): ex = dict(extra or {}) ex["tags"] = list(src.tags) return Item(src.name, kind, url=str(url or ""), title=_txt(title)[:300], text=_txt(text)[:6000], author=_txt(author)[:80], published=str(published or ""), source=src.category, extra=ex) # ---------------- rss ---------------- def _arxiv_api(src, limit): """arxiv RSS часто пустой -> официальный API (Atom), тот же формат.""" m = re.search(r"rss/([A-Za-z\-.]+)$", src.url) cat = m.group(1) if m else "cs.AI" url = ("https://export.arxiv.org/api/query?search_query=cat:" + cat + "&sortBy=submittedDate&sortOrder=descending&max_results=" + str(max(limit, 5))) r = net.get(url, headers=UA) if r.status != 200 or "(.*?)", text, re.S | re.I): blob = m.group(1) g = lambda t: (re.search(rf"<{t}[^>]*>(.*?)", blob, re.S | re.I) or [None, ""])[1] out.append(_item(src, "entry", _txt(g("link")), g("title"), g("description"), g("author"), g("pubDate"))) if len(out) >= limit: break return out # ---------------- json adapters ---------------- def _hn_top(src, limit): ids = net.get(src.url, headers=UA, json_mode=True).json() or [] out = [] for sid in ids[:limit]: d = net.get(f"https://hacker-news.firebaseio.com/v0/item/{sid}.json", headers=UA, json_mode=True).json() or {} if not d.get("title"): continue out.append(_item(src, "post", d.get("url") or f"https://news.ycombinator.com/item?id={sid}", d.get("title"), d.get("text", ""), d.get("by", ""), str(d.get("time", "")), {"points": d.get("score"), "comments": d.get("descendants"), "hn": f"https://news.ycombinator.com/item?id={sid}"})) return out def _reddit_listing(src, limit): """Reddit без ключей: .json часто 403 с серверов, тогда .rss (Atom), тогда pullpush.""" sub_m = re.search(r"reddit\.com/r/([^/]+)", src.url) rss_first = f"https://www.reddit.com/r/{sub_m.group(1)}/hot.rss" if sub_m else None import time children = [] if rss_first: rr = net.get(rss_first, headers=UA) if rr.status == 200 and "= limit: break return out def _tg_web(src, limit): r = net.get(src.url, headers=UA) if r.status != 200: return [] soup = BeautifulSoup(r.text, "lxml") out = [] for msg in soup.select("div.tgme_widget_message_wrap")[-limit:]: text_el = msg.select_one("div.tgme_widget_message_text") link_el = msg.select_one("a.tgme_widget_message_date") text = text_el.get_text("\n", strip=True) if text_el else "" link = str(link_el.get("href") or src.url) if link_el else src.url if not text: continue out.append(_item(src, "post", link, text.split("\n")[0][:120], text, "", "", {})) return out def _html_listing(src, limit): r = net.get(src.url, headers=UA) if r.status != 200: return [] soup = BeautifulSoup(r.text, "lxml") inc = src.extra.get("include_prefixes") or [] inc_c = src.extra.get("include_contains") or [] exc = set(src.extra.get("exclude_exact") or []) base = "{0.scheme}://{0.netloc}".format(urllib.parse.urlsplit(src.url)) seen, links = set(), [] for a in soup.find_all("a", href=True): href = urllib.parse.urljoin(base, str(a["href"])) path = urllib.parse.urlsplit(href).path if path in exc or len(path) < 6: continue if inc and not any(path.startswith(p) for p in inc): continue if inc_c and not any(c in path for c in inc_c): continue if href in seen: continue seen.add(href) links.append((href, a.get_text(" ", strip=True))) if len(links) >= limit: break out = [] for href, title in links: text = "" if src.detail: d = net.get(href, headers=UA) if d.status == 200: ds = BeautifulSoup(d.text, "lxml") for bad in ds(["script", "style", "nav", "footer", "header"]): bad.decompose() paras = [p.get_text(" ", strip=True) for p in ds.find_all("p")] text = " ".join(paras)[:4000] if not title: t = ds.find("h1") or ds.title title = t.get_text(" ", strip=True) if t else href out.append(_item(src, "entry", href, title or href, text)) return out ADAPTERS = { "rss": _rss, "hn_top": _hn_top, "reddit_listing": _reddit_listing, "hf_trending": _hf_generic, "hf_generic": _hf_generic, "hf_models": _hf_models, "hf_daily_papers": _hf_daily_papers, "hf_rows": _hf_rows, "openalex": _openalex, "s2": _s2, "samsung": _samsung, "lg_ai": _lg_ai, "github_org": _github_org, "discourse": _discourse, "xenforo_latest": _xenforo_latest, "tg_web": _tg_web, "html_listing": _html_listing, } def fetch(src, limit=None): fn = ADAPTERS.get(src.adapter) if not fn: return [] n = limit or src.limit try: return fn(src, n) or [] except Exception: return [] SLOW_HOSTS = ("reddit.com", "pullpush.io", "api.github.com", "huggingface.co") def fetch_many(sources, limit=None, workers=8): """Параллельный обход, но медленные хосты (reddit/github/hf) — по очереди с паузой.""" import time from concurrent.futures import ThreadPoolExecutor slow = [s for s in sources if any(h in s.url for h in SLOW_HOSTS)] fast = [s for s in sources if s not in slow] items, report = [], [] with ThreadPoolExecutor(max_workers=workers) as ex: futs = [(ex.submit(fetch, s, limit), s) for s in fast] for f, s in futs: got = f.result() report.append((s.name, len(got))) items.extend(got) for s in slow: got = fetch(s, limit) if not got and "reddit.com" in s.url: time.sleep(7) got = fetch(s, limit) report.append((s.name, len(got))) items.extend(got) time.sleep(3.5 if "reddit.com" in s.url else 1.0) return items, report