Spaces:
Runtime error
Runtime error
Refactor VideoTemplateEditor component: remove AI editing features and related state management, streamline template loading, and update styles for improved layout. Enhance CSS for responsive design and visual consistency across various components, including dropdowns, sync stats, and quiz elements. Remove deprecated premium badge styles and adjust layout for chat components.
66de9c8 Download backend/app/pipeline/ingest.py from firebird-technologies/NoteStack-Test: direct link, hf CLI and curl.
- Browser
- Download file 32.9 kB
-
https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/main/backend/app/pipeline/ingest.py
- Command line
-
hf download hf://spaces/firebird-technologies/NoteStack-Test/backend/app/pipeline/ingest.py
-
curl -L -o ingest.py https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/main/backend/app/pipeline/ingest.py
32.9 kB
| """Ingestion: feed -> raw HTML to storage -> clean markdown files in the workspace corpus -> INDEX.md. | |
| Three ways in, one pipeline out: | |
| - feeds (Substack, Ghost, Medium, any RSS/Atom), with Substack's archive API for posts past the feed | |
| - sites with no feed, crawled through their sitemap and links (Firecrawl renders JavaScript sites) | |
| - one article by URL | |
| - an uploaded file (markdown, text, HTML or PDF) | |
| All of them end in `store_entries`, which writes documents, raw HTML and corpus files. | |
| """ | |
| import hashlib | |
| import io | |
| import logging | |
| import re | |
| import time | |
| import uuid | |
| from concurrent.futures import ThreadPoolExecutor | |
| from dataclasses import dataclass | |
| from datetime import UTC, datetime | |
| from time import mktime | |
| from urllib.parse import urljoin, urlparse | |
| import feedparser | |
| import httpx | |
| from bs4 import BeautifulSoup | |
| from sqlalchemy import select | |
| from sqlalchemy.orm import Session | |
| from app.corpus import INDEX, Corpus, index_line, post_path, render_post, slugify | |
| from app.models import Document, Job, Source | |
| from app.services import firecrawl | |
| from app.services.jobs import update_job | |
| from app.services.storage import keys, storage | |
| log = logging.getLogger(__name__) | |
| _DROP = re.compile(r"(subscribe|share this post|leave a comment|thanks for reading)", re.I) | |
| UA = {"User-Agent": "Mozilla/5.0 (compatible; NotestackBot/0.2; +https://notestack.ai)"} | |
| IMPORTS_FEED = "notestack:imports" | |
| SITE_PREFIX = "site:" # feed_url of a source crawled as a website instead of read from a feed | |
| class FeedNotFound(ValueError): | |
| pass | |
| # Feed discovery | |
| def _with_scheme(url: str) -> str: | |
| url = url.strip() | |
| return url if url.startswith(("http://", "https://")) else f"https://{url}" | |
| def normalize_feed_url(url: str) -> tuple[str, str]: | |
| """Best guess without network: accept 'name.substack.com', a post URL, or any feed URL.""" | |
| url = _with_scheme(url) | |
| parsed = urlparse(url) | |
| host = parsed.netloc.lower() | |
| if host.endswith("substack.com") or "/p/" in parsed.path: | |
| return f"{parsed.scheme}://{host}/feed", "substack" | |
| if parsed.path.rstrip("/").endswith(("/feed", "/rss", ".xml", "/atom")): | |
| return url, "rss" | |
| if host.endswith("medium.com"): | |
| return f"https://medium.com/feed{parsed.path.rstrip('/')}", "medium" | |
| return f"{parsed.scheme}://{host}/feed", "rss" | |
| def _parse_feed(url: str, timeout: float = 15) -> tuple[feedparser.FeedParserDict | None, httpx.Response | None]: | |
| try: | |
| resp = httpx.get(url, follow_redirects=True, timeout=timeout, headers=UA) | |
| except httpx.HTTPError: | |
| return None, None | |
| if resp.status_code >= 400: | |
| return None, resp | |
| parsed = feedparser.parse(resp.content) | |
| if parsed.get("version") and (parsed.entries or parsed.feed.get("title")): | |
| return parsed, resp | |
| return None, resp | |
| class DiscoveredFeed: | |
| feed_url: str | |
| platform: str | |
| title: str | None | |
| site_url: str | |
| _FEED_SUFFIX = re.compile(r"/(feed|rss|atom)(\.xml)?/?$|/[^/]+\.xml$", re.I) | |
| def _as_site(url: str, title: str | None, site: str) -> DiscoveredFeed: | |
| start = _FEED_SUFFIX.sub("", url).rstrip("/") or site | |
| return DiscoveredFeed(f"{SITE_PREFIX}{start}", "website", title, site) | |
| def discover_feed(url: str) -> DiscoveredFeed: | |
| """Pick how to read whatever the writer pasted, or raise FeedNotFound with a clear reason. | |
| Substack keeps its feed plus archive API (complete and free). With Firecrawl configured, every | |
| other site is crawled with it, since feeds often carry only excerpts or the latest few posts. | |
| """ | |
| found = _discover_feed(url) | |
| if firecrawl.enabled() and found.platform not in ("substack", "website"): | |
| return _as_site(_with_scheme(url), found.title, found.site_url) | |
| return found | |
| def _discover_feed(url: str) -> DiscoveredFeed: | |
| url = _with_scheme(url) | |
| guess, platform = normalize_feed_url(url) | |
| parsed_url = urlparse(url) | |
| site = f"{parsed_url.scheme}://{parsed_url.netloc}" | |
| candidates = [guess, url, f"{site}/feed", f"{site}/rss", f"{site}/rss.xml", f"{site}/feed.xml", f"{site}/atom.xml"] | |
| seen: set[str] = set() | |
| last: httpx.Response | None = None | |
| page: httpx.Response | None = None # the pasted address, when it is a working web page | |
| for candidate in candidates: | |
| if candidate in seen: | |
| continue | |
| seen.add(candidate) | |
| parsed, resp = _parse_feed(candidate) | |
| last = resp or last | |
| if candidate == url and resp is not None and resp.status_code < 400: | |
| page = resp | |
| if parsed: | |
| return DiscoveredFeed(candidate, _platform(candidate, parsed, platform), parsed.feed.get("title"), site) | |
| if resp is not None and resp.status_code < 400 and "html" in resp.headers.get("content-type", ""): | |
| # A web page: look for <link rel="alternate" type="application/rss+xml">. | |
| soup = BeautifulSoup(resp.text, "html.parser") | |
| for link in soup.find_all("link", rel="alternate"): | |
| if "rss" in (link.get("type") or "") or "atom" in (link.get("type") or ""): | |
| href = urljoin(str(resp.url), link.get("href", "")) | |
| if href and href not in seen: | |
| seen.add(href) | |
| found, _ = _parse_feed(href) | |
| if found: | |
| return DiscoveredFeed(href, _platform(href, found, platform), found.feed.get("title"), site) | |
| if page is not None and "html" in page.headers.get("content-type", ""): | |
| # No feed, but the site is up: crawl it as a website instead. | |
| soup = BeautifulSoup(page.text, "html.parser") | |
| title = _meta(soup, "og:site_name", "og:title") or (soup.title.get_text(strip=True) if soup.title else None) | |
| return _as_site(url, title, site) | |
| if last is not None and last.status_code == 404: | |
| raise FeedNotFound(f"{parsed_url.netloc} returned 404. Check the address in your browser.") | |
| raise FeedNotFound("We couldn't find a feed at that address.") | |
| def _platform(feed_url: str, parsed, guess: str) -> str: | |
| generator = (parsed.feed.get("generator") or "").lower() | |
| if "substack" in feed_url or "substack" in generator: | |
| return "substack" | |
| if "ghost" in generator: | |
| return "ghost" | |
| if "medium.com" in feed_url: | |
| return "medium" | |
| if "wordpress" in generator: | |
| return "wordpress" | |
| return guess if guess != "rss" else "rss" | |
| # Text extraction | |
| def html_to_sections(html: str) -> list[tuple[str | None, str]]: | |
| soup = BeautifulSoup(html, "html.parser") | |
| for tag in soup(["script", "style", "nav", "footer", "form", "button", "iframe", "noscript", "svg"]): | |
| tag.decompose() | |
| sections: list[tuple[str | None, list[str]]] = [(None, [])] | |
| for el in soup.find_all(["h1", "h2", "h3", "p", "li", "blockquote", "pre", "img"]): | |
| if el.name == "img": | |
| # Keep images as Markdown so answers can show them (web images only, no tracking pixels). | |
| src = (el.get("src") or el.get("data-src") or "").strip() | |
| width = str(el.get("width") or "") | |
| if src.startswith(("http://", "https://")) and not (width.isdigit() and int(width) < 50): | |
| alt = re.sub(r"[\[\]\n]", " ", el.get("alt") or "").strip() | |
| sections[-1][1].append(f"") | |
| continue | |
| if el.name == "pre": | |
| # Code keeps its line breaks, as a fenced block. | |
| code = el.get_text().strip("\n") | |
| if code.strip(): | |
| lang = next((c.split("-", 1)[1] for c in (el.find("code") or el).get("class") or [] | |
| if c.startswith(("language-", "lang-"))), "") | |
| sections[-1][1].append(f"```{lang}\n{code}\n```") | |
| continue | |
| if el.find_parent("pre"): | |
| continue | |
| text = el.get_text(" ", strip=True) | |
| if not text or (len(text) < 120 and _DROP.search(text)): | |
| continue | |
| if el.name in {"h1", "h2", "h3"}: | |
| sections.append((text, [])) | |
| else: | |
| sections[-1][1].append(text) | |
| return [(h, "\n\n".join(p)) for h, p in sections if p] | |
| _MD_IMAGE_LINE = re.compile(r"^!\[[^\]]*\]\(https?://[^)\s]+[^)]*\)$") | |
| def markdown_to_sections(text: str) -> list[tuple[str | None, str]]: | |
| sections: list[tuple[str | None, list[str]]] = [(None, [])] | |
| para: list[str] = [] | |
| def flush() -> None: | |
| if para: | |
| sections[-1][1].append(" ".join(para).strip()) | |
| para.clear() | |
| fence: list[str] | None = None # lines of an open ``` block, kept verbatim | |
| for line in text.splitlines(): | |
| stripped = line.strip() | |
| if fence is not None: | |
| fence.append(line) | |
| if stripped.startswith("```"): | |
| sections[-1][1].append("\n".join(fence)) | |
| fence = None | |
| continue | |
| if stripped.startswith("```"): | |
| flush() | |
| fence = [stripped] | |
| continue | |
| heading = re.match(r"^#{1,3}\s+(.*)", stripped) | |
| if _MD_IMAGE_LINE.match(stripped): | |
| flush() | |
| sections[-1][1].append(stripped) # images stay on their own line | |
| elif heading: | |
| flush() | |
| sections.append((heading.group(1).strip(), [])) | |
| elif not stripped: | |
| flush() | |
| else: | |
| para.append(stripped) | |
| if fence is not None: # unclosed fence: close it so the block still renders | |
| sections[-1][1].append("\n".join([*fence, "```"])) | |
| flush() | |
| return [(h, "\n\n".join(p)) for h, p in sections if p] | |
| def pdf_to_sections(data: bytes) -> list[tuple[str | None, str]]: | |
| from pypdf import PdfReader | |
| reader = PdfReader(io.BytesIO(data)) | |
| text = "\n\n".join((page.extract_text() or "") for page in reader.pages) | |
| # PDF text keeps hard line breaks inside paragraphs; blank lines separate paragraphs. | |
| paragraphs = [re.sub(r"\s*\n\s*", " ", p).strip() for p in re.split(r"\n\s*\n", text)] | |
| return [(None, "\n\n".join(p for p in paragraphs if p))] if any(paragraphs) else [] | |
| def _meta(soup: BeautifulSoup, *names: str) -> str | None: | |
| for name in names: | |
| tag = soup.find("meta", attrs={"property": name}) or soup.find("meta", attrs={"name": name}) | |
| if tag and tag.get("content"): | |
| return tag["content"].strip() | |
| return None | |
| def _parse_date(value: str | None) -> datetime | None: | |
| if not value: | |
| return None | |
| try: | |
| dt = datetime.fromisoformat(value.replace("Z", "+00:00")) | |
| return dt if dt.tzinfo else dt.replace(tzinfo=UTC) | |
| except ValueError: | |
| return None | |
| class FeedEntry: | |
| title: str | |
| url: str | |
| published_at: datetime | None | |
| html: str | |
| sections: list[tuple[str | None, str]] | None = None # set when the source is not HTML | |
| _MD_IMAGE = re.compile(r"!\[([^\]]*)\]\(([^)\s]*)[^)]*\)") | |
| _MD_LINK = re.compile(r"(?<!!)\[([^\]]*)\]\([^)]*\)") | |
| def _clean_markdown(md: str) -> str: | |
| """Firecrawl markdown to prose: keep link text, keep web images on their own line (drop data: URIs | |
| and relative paths, which would not load outside the site).""" | |
| def image(m: re.Match) -> str: | |
| alt, src = m.group(1).replace("\n", " ").strip(), m.group(2) | |
| return f"\n\n\n\n" if src.startswith(("http://", "https://")) else "" | |
| return _MD_LINK.sub(r"\1", _MD_IMAGE.sub(image, md)) | |
| def _firecrawl_entry(url: str) -> FeedEntry: | |
| data = firecrawl.scrape(url) | |
| meta = data.get("metadata") or {} | |
| text = _clean_markdown(data.get("markdown") or "") | |
| heading = re.search(r"^#\s+(.+)$", text, re.M) | |
| title = meta.get("ogTitle") or meta.get("title") or (heading.group(1).strip() if heading else url) | |
| if isinstance(title, list): | |
| title = title[0] | |
| sections = markdown_to_sections(text) | |
| if sections and sections[0][0] and heading and sections[0][0] == heading.group(1).strip(): | |
| sections[0] = (None, sections[0][1]) | |
| if not sections or sum(len(t) for _, t in sections) < 200: | |
| raise FeedNotFound("We could not find article text on that page.") | |
| published = None | |
| for key in ("publishedTime", "article:published_time", "datePublished", "og:published_time", "modifiedTime"): | |
| value = meta.get(key) | |
| if published := _parse_date(value[0] if isinstance(value, list) else value): | |
| break | |
| final = meta.get("sourceURL") or meta.get("url") or url | |
| return FeedEntry(title=str(title)[:1000], url=final, published_at=published, html="", sections=sections) | |
| def extract_article(url: str) -> FeedEntry: | |
| """Fetch one web page and keep the article body. Firecrawl first when configured (it renders JS).""" | |
| url = _with_scheme(url) | |
| if firecrawl.enabled(): | |
| try: | |
| return _firecrawl_entry(url) | |
| except (firecrawl.FirecrawlError, httpx.HTTPError, FeedNotFound): | |
| log.warning("firecrawl scrape failed for %s, falling back to plain HTTP", url, exc_info=True) | |
| resp = httpx.get(url, follow_redirects=True, timeout=30, headers=UA) | |
| if resp.status_code >= 400: | |
| raise FeedNotFound(f"That page returned {resp.status_code}.") | |
| soup = BeautifulSoup(resp.text, "html.parser") | |
| title = _meta(soup, "og:title", "twitter:title") or (soup.title.get_text(strip=True) if soup.title else url) | |
| published = _parse_date(_meta(soup, "article:published_time", "og:published_time", "date")) | |
| if not published and (t := soup.find("time", datetime=True)): | |
| published = _parse_date(t["datetime"]) | |
| body = soup.find("article") or soup.find("main") or soup.find(class_=re.compile("post-content|entry-content|body")) | |
| if body is None: | |
| # Fall back to the element holding the most paragraph text. | |
| candidates = soup.find_all(["div", "section"]) | |
| body = max(candidates, key=lambda el: sum(len(p.get_text()) for p in el.find_all("p", recursive=False)), | |
| default=soup.body or soup) | |
| html = str(body) | |
| if not html_to_sections(html): | |
| raise FeedNotFound("We could not find article text on that page.") | |
| return FeedEntry(title=title[:1000], url=str(resp.url), published_at=published, html=html) | |
| # Feed fetching | |
| def fetch_feed(feed_url: str, limit: int) -> tuple[str | None, list[FeedEntry]]: | |
| resp = httpx.get(feed_url, follow_redirects=True, timeout=30, headers=UA) | |
| resp.raise_for_status() | |
| parsed = feedparser.parse(resp.content) | |
| entries = [] | |
| for e in parsed.entries[:limit]: | |
| content = (e.get("content") or [{}])[0].get("value") or e.get("summary") or "" | |
| published = e.get("published_parsed") or e.get("updated_parsed") | |
| entries.append( | |
| FeedEntry( | |
| title=e.get("title") or "Untitled", | |
| url=e.get("link") or "", | |
| published_at=datetime.fromtimestamp(mktime(published), UTC) if published else None, | |
| html=content, | |
| ) | |
| ) | |
| return parsed.feed.get("title"), entries | |
| _NOT_POSTS = re.compile( | |
| r"/(tag|tags|category|categories|author|authors|page|search|login|signin|signup|register|account|cart|" | |
| r"privacy|terms|legal|cookies?|about|contact|pricing|careers|jobs|feed|rss|wp-admin|wp-json|cdn-cgi|_next)(/|$)", | |
| re.I, | |
| ) | |
| def _post_urls(links: list[str], start: str, limit: int) -> list[str]: | |
| """Likely article pages among a site's links: same site, not the index itself, not utility pages.""" | |
| base = urlparse(start) | |
| host = base.netloc.lower().removeprefix("www.") | |
| prefix = base.path.rstrip("/") | |
| out: list[str] = [] | |
| seen: set[str] = set() | |
| for link in links: | |
| p = urlparse(link) | |
| path = p.path.rstrip("/") | |
| if p.scheme not in ("http", "https") or p.netloc.lower().removeprefix("www.") != host: | |
| continue | |
| if not path or path == prefix or _NOT_POSTS.search(path + "/"): | |
| continue | |
| if re.search(r"\.(?!html?$)[a-z0-9]{2,5}$", path, re.I): # files: images, pdf, xml | |
| continue | |
| clean = f"{p.scheme}://{p.netloc}{path}" | |
| if clean not in seen: | |
| seen.add(clean) | |
| out.append(clean) | |
| # Pasted a blog index like /blog: keep what lives under it when anything does. | |
| if prefix: | |
| under = [u for u in out if urlparse(u).path.startswith(prefix + "/")] | |
| out = under or out | |
| return out[:limit] | |
| def _plain_links(url: str) -> list[str]: | |
| """Without Firecrawl: the sitemap plus links in the page HTML (misses posts rendered by JavaScript).""" | |
| parsed = urlparse(url) | |
| links: list[str] = [] | |
| for sitemap in (f"{parsed.scheme}://{parsed.netloc}/sitemap.xml", f"{parsed.scheme}://{parsed.netloc}/sitemap_index.xml"): | |
| try: | |
| resp = httpx.get(sitemap, follow_redirects=True, timeout=20, headers=UA) | |
| except httpx.HTTPError: | |
| continue | |
| if resp.status_code < 400: | |
| links += re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", resp.text) | |
| try: | |
| resp = httpx.get(url, follow_redirects=True, timeout=20, headers=UA) | |
| if resp.status_code < 400: | |
| soup = BeautifulSoup(resp.text, "html.parser") | |
| links += [urljoin(str(resp.url), a["href"]) for a in soup.find_all("a", href=True)] | |
| except httpx.HTTPError: | |
| pass | |
| return links | |
| def _title_from_url(u: str) -> str: | |
| slug = urlparse(u).path.rstrip("/").rsplit("/", 1)[-1] | |
| slug = re.sub(r"\.[a-z0-9]+$", "", slug) | |
| return slug.replace("-", " ").replace("_", " ").strip().capitalize() or urlparse(u).netloc | |
| def crawl_site(url: str, limit: int, on_found=None, on_post=None, read_limit: int | None = None) -> list[FeedEntry]: | |
| """Find the posts on a site with no feed and read each one. Past read_limit, pages are only listed (title from the | |
| URL, no html): they show the writer what the rest of their archive holds without paying to read it.""" | |
| if firecrawl.enabled(): | |
| links = firecrawl.map_site(url, max(limit * 4, 100)) | |
| else: | |
| links = _plain_links(url) | |
| urls = _post_urls(links, url, limit) | |
| if not urls: | |
| hint = "" if firecrawl.enabled() else " If the site builds its pages with JavaScript, set FIRECRAWL_API_KEY." | |
| raise FeedNotFound(f"We could not find any posts on {urlparse(url).netloc}.{hint}") | |
| if on_found: | |
| on_found(len(urls)) | |
| def read(u: str) -> FeedEntry | None: | |
| try: | |
| return extract_article(u) | |
| except (FeedNotFound, httpx.HTTPError, firecrawl.FirecrawlError): | |
| return None | |
| to_read = urls if read_limit is None else urls[:read_limit] | |
| entries: list[FeedEntry] = [] | |
| with ThreadPoolExecutor(max_workers=4) as pool: | |
| for i, entry in enumerate(pool.map(read, to_read)): | |
| if entry: | |
| entries.append(entry) | |
| if on_post: | |
| on_post(i + 1, len(to_read)) | |
| entries += [FeedEntry(title=_title_from_url(u), url=u, published_at=None, html="") for u in urls[len(to_read):]] | |
| if not entries: | |
| raise FeedNotFound(f"We found {len(urls)} pages on {urlparse(url).netloc} but none had article text.") | |
| return entries | |
| def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None, | |
| body_budget: int | None = None) -> list[FeedEntry]: | |
| """Posts beyond the ~20 the RSS feed carries, through Substack's public archive API. Only the first body_budget | |
| posts get their body fetched; the rest are listed from the archive metadata alone (title, date, link).""" | |
| out: list[FeedEntry] = [] | |
| offset = 0 | |
| with httpx.Client(follow_redirects=True, timeout=30, headers=UA) as client: | |
| while len(out) + len(known_urls) < limit: | |
| resp = client.get(f"{site}/api/v1/archive", params={"sort": "new", "offset": offset, "limit": 50}) | |
| if resp.status_code >= 400: | |
| break | |
| page = resp.json() | |
| if not page: | |
| break | |
| offset += len(page) | |
| for item in page: | |
| url = item.get("canonical_url") or f"{site}/p/{item.get('slug')}" | |
| if url in known_urls or item.get("type") not in (None, "newsletter", "thread"): | |
| continue | |
| if len(out) + len(known_urls) >= limit: | |
| break | |
| if body_budget is not None and len(out) >= body_budget: | |
| out.append(FeedEntry(title=item.get("title") or "Untitled", url=url, | |
| published_at=_parse_date(item.get("post_date")), html="")) | |
| continue | |
| post = client.get(f"{site}/api/v1/posts/{item['slug']}") | |
| if post.status_code >= 400: | |
| continue | |
| body = post.json().get("body_html") or item.get("truncated_body_text") or "" | |
| if not body: | |
| continue | |
| out.append(FeedEntry(title=item.get("title") or "Untitled", url=url, | |
| published_at=_parse_date(item.get("post_date")), html=body)) | |
| time.sleep(0.2) # be polite to Substack | |
| if on_page: | |
| on_page(len(out)) | |
| return out | |
| # Storing | |
| def source_slug(source: Source) -> str: | |
| if source.feed_url == IMPORTS_FEED: | |
| return "imports" | |
| return slugify(urlparse(source.site_url or source.feed_url).netloc or str(source.id), 50) | |
| def rebuild_index(db: Session, workspace_id: uuid.UUID) -> str: | |
| docs = db.scalars( | |
| select(Document).where(Document.workspace_id == workspace_id, Document.path.is_not(None)) | |
| ).all() | |
| rows = sorted( | |
| (index_line(d.path, d.title, d.published_at, (d.metadata_json or {}).get("headings", [])) for d in docs), | |
| reverse=True, | |
| ) | |
| header = "# Archive index\n\ndate | path | title | section headings\n\n" | |
| return header + "\n".join(rows) + "\n" | |
| def imports_source(db: Session, workspace_id: uuid.UUID) -> Source: | |
| """Single articles and uploaded files live under one built in source that does not count to limits.""" | |
| source = db.scalar(select(Source).where(Source.workspace_id == workspace_id, Source.feed_url == IMPORTS_FEED)) | |
| if not source: | |
| source = Source(workspace_id=workspace_id, feed_url=IMPORTS_FEED, platform="imports", | |
| title="Imported posts and files", sync_status="ok") | |
| db.add(source) | |
| db.flush() | |
| return source | |
| def store_entries( | |
| db: Session, source: Source, entries: list[FeedEntry], progress=None | |
| ) -> tuple[int, int, list[uuid.UUID]]: | |
| """Write documents, raw HTML and corpus files. Returns (indexed, skipped, changed document ids).""" | |
| corpus = Corpus(source.workspace_id) | |
| folder = source_slug(source) | |
| indexed = skipped = 0 | |
| changed: list[uuid.UUID] = [] | |
| files: dict[str, str] = {} | |
| taken = set(db.scalars(select(Document.path).where(Document.workspace_id == source.workspace_id)).all()) | |
| total = max(len(entries), 1) | |
| for i, entry in enumerate(entries): | |
| payload = entry.html or "\n\n".join(t for _, t in (entry.sections or [])) | |
| content_hash = hashlib.sha256(payload.encode()).hexdigest() | |
| doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url)) | |
| if doc and doc.content_hash == content_hash and doc.path: | |
| skipped += 1 | |
| continue | |
| sections = entry.sections if entry.sections is not None else html_to_sections(entry.html) | |
| if not sections: | |
| skipped += 1 | |
| continue | |
| # Postgres text cannot hold NUL bytes, and some PDFs (icon glyphs) extract with them. | |
| sections = [(h and h.replace("\x00", ""), t.replace("\x00", "")) for h, t in sections] | |
| entry.title = entry.title.replace("\x00", "") | |
| if not doc: | |
| doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url) | |
| db.add(doc) | |
| if not doc.path: | |
| path = post_path(folder, entry.title, entry.url, entry.published_at) | |
| n = 2 | |
| while path in taken: | |
| path = path.removesuffix(".md").rsplit("--", 1)[0] + f"--{n}.md" | |
| n += 1 | |
| doc.path = path | |
| taken.add(path) | |
| doc.title = entry.title | |
| doc.published_at = entry.published_at | |
| doc.content_hash = content_hash | |
| if entry.html: | |
| doc.raw_html_key = storage.put_text( | |
| keys.raw_html(source.workspace_id, source.id, doc.id), entry.html, "text/html; charset=utf-8" | |
| ) | |
| doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections) | |
| doc.metadata_json = { | |
| **{k: v for k, v in (doc.metadata_json or {}).items() if k != "locked"}, | |
| "headings": [h for h, _ in sections if h], | |
| "words": len(doc.clean_text.split()), | |
| } | |
| files[doc.path] = render_post( | |
| title=entry.title, url=entry.url, published_at=entry.published_at, source=folder, sections=sections | |
| ) | |
| indexed += 1 | |
| changed.append(doc.id) | |
| db.commit() | |
| if progress: | |
| progress(i + 1, total, entry.title) | |
| db.flush() | |
| files[INDEX] = rebuild_index(db, source.workspace_id) | |
| corpus.write_files(files) | |
| db.commit() | |
| return indexed, skipped, changed | |
| # A sync discovers up to SCAN_CAP posts (or the plan's own limit, if higher) but only indexes the plan's | |
| # indexed_posts. The rest are listed, not indexed: title, date and link only, no text and no corpus file. They exist | |
| # to show the writer how much of their archive is waiting behind an upgrade. Upgrading re-syncs and indexes them. | |
| SCAN_CAP = 200 | |
| def _newest_first(entries: list[FeedEntry]) -> list[FeedEntry]: | |
| """Dated posts newest first; undated ones (site crawls) keep their discovery order after them.""" | |
| dated = sorted((e for e in entries if e.published_at), key=lambda e: e.published_at.timestamp(), reverse=True) | |
| return dated + [e for e in entries if not e.published_at] | |
| def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple[int, set[str]]: | |
| """List posts beyond the plan limit without indexing them: title, date and link only. Returns (count, corpus paths | |
| to remove): a post that was indexed before (a newer post pushed it out, or the plan went down) loses its file.""" | |
| locked = 0 | |
| remove: set[str] = set() | |
| for entry in entries: | |
| doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url)) | |
| if not doc: | |
| doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url) | |
| db.add(doc) | |
| if doc.path: | |
| remove.add(doc.path) | |
| doc.path = None | |
| doc.title = entry.title | |
| doc.published_at = entry.published_at | |
| doc.content_hash = None # indexed from scratch once the plan allows it | |
| doc.clean_text = "" | |
| doc.metadata_json = {"locked": True} | |
| locked += 1 | |
| db.commit() | |
| return locked, remove | |
| def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict: | |
| source.sync_status = "syncing" | |
| fetch_limit = max(SCAN_CAP, max_posts) | |
| # Live counts the Sources page shows while this runs: found on the site, fetched (read), indexed. | |
| live = {"stage": "fetching", "found": 0, "fetched": 0, "fetch_total": 0, "indexed": 0, "index_total": 0} | |
| def report(progress: float | None = None, message: str | None = None, **counts) -> None: | |
| live.update(counts) | |
| update_job(db, job, progress=progress, message=message, result=dict(live)) | |
| update_job(db, job, status="running", progress=0.02, message="Contacting your feed", result=dict(live)) | |
| if source.feed_url.startswith(SITE_PREFIX): | |
| update_job(db, job, message="Mapping your site") | |
| entries = crawl_site( | |
| source.feed_url.removeprefix(SITE_PREFIX), fetch_limit, | |
| on_found=lambda n: report(0.05, f"Found {n} pages, reading them", found=n), | |
| on_post=lambda done, total: report(0.05 + 0.05 * done / total, f"Read {done} of {total} pages", | |
| fetched=done, fetch_total=total), | |
| read_limit=max(max_posts, 1), | |
| ) | |
| title = None | |
| else: | |
| title, entries = fetch_feed(source.feed_url, fetch_limit) | |
| report(found=len(entries), fetched=len(entries), fetch_total=len(entries)) | |
| source.title = source.title or title | |
| if source.platform == "substack" and len(entries) < fetch_limit: | |
| site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}" | |
| report(0.05, "Reading your Substack archive") | |
| try: | |
| entries += fetch_substack_archive( | |
| site, {e.url for e in entries}, fetch_limit, | |
| on_page=lambda n: report(message=f"Found {len(entries) + n} posts in the archive", | |
| found=len(entries) + n, fetched=len(entries) + n, | |
| fetch_total=len(entries) + n), | |
| body_budget=max(0, max_posts - len(entries)), | |
| ) | |
| except (httpx.HTTPError, ValueError): | |
| log.warning("substack archive fetch failed for %s", site, exc_info=True) | |
| entries = _newest_first(entries) | |
| available, beyond = entries[:max_posts], entries[max_posts:] | |
| def progress(done: int, total: int, post_title: str) -> None: | |
| report(0.1 + 0.7 * done / total, f"{post_title[:80]} is in orbit", stage="indexing", indexed=done) | |
| report(0.1, f"Indexing {len(available)} posts", stage="indexing", found=len(entries), index_total=len(available)) | |
| indexed, skipped, changed = store_entries(db, source, available, progress) | |
| locked = 0 | |
| if beyond: | |
| update_job(db, job, progress=0.85, | |
| message=f"Found {len(beyond)} more posts. Your plan indexes the latest {max_posts}") | |
| locked, remove = store_locked(db, source, beyond) | |
| if remove: | |
| Corpus(source.workspace_id).write_files({INDEX: rebuild_index(db, source.workspace_id)}, remove=remove) | |
| source.sync_status = "ok" | |
| source.sync_error = None | |
| source.last_synced_at = datetime.now(UTC) | |
| db.commit() | |
| return {"indexed": indexed, "skipped": skipped, "found": len(entries), "limit": max_posts, | |
| "capped": locked > 0, "locked": locked, "available": len(available), | |
| "changed": [str(i) for i in changed]} | |
| _VTT_TIME = re.compile(r"^(\d+:)?\d{1,2}:\d{2}[.,]\d{3}\s+-->\s+") | |
| def vtt_to_text(text: str) -> str: | |
| """The spoken words of a WebVTT transcript: the header, notes, cue ids, timestamps and markup dropped, a line | |
| repeated from the cue before (rolling captions) kept once, the rest joined into paragraphs.""" | |
| lines: list[str] = [] | |
| in_cue = False | |
| skipping = False # inside a NOTE / STYLE / REGION block, until the blank line | |
| for raw in text.lstrip("\ufeff").splitlines(): | |
| line = raw.strip() | |
| if not line: | |
| in_cue = skipping = False | |
| continue | |
| if skipping or line.startswith(("WEBVTT", "NOTE", "STYLE", "REGION")) and not in_cue: | |
| skipping = True | |
| continue | |
| if _VTT_TIME.match(line): | |
| in_cue = True | |
| continue | |
| if not in_cue: | |
| continue # a cue id | |
| line = re.sub(r"<[^>]+>", "", line).strip() | |
| if line and (not lines or lines[-1] != line): | |
| lines.append(line) | |
| return "\n\n".join(" ".join(lines[i:i + 8]) for i in range(0, len(lines), 8)) | |
| def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntry: | |
| name = filename.rsplit("/", 1)[-1] | |
| stem = re.sub(r"\.[A-Za-z0-9]+$", "", name).replace("-", " ").replace("_", " ").strip() or "Untitled" | |
| lower = name.lower() | |
| url = f"upload://{name}" | |
| if content_type == "application/pdf" or lower.endswith(".pdf"): | |
| return FeedEntry(title=stem, url=url, published_at=None, html="", sections=pdf_to_sections(data)) | |
| text = data.decode("utf-8", errors="replace") | |
| if content_type == "text/html" or lower.endswith((".html", ".htm")): | |
| soup = BeautifulSoup(text, "html.parser") | |
| title = soup.title.get_text(strip=True) if soup.title else stem | |
| return FeedEntry(title=title, url=url, published_at=None, html=text) | |
| if lower.endswith(".vtt") or text.lstrip("\ufeff").startswith("WEBVTT"): | |
| text = vtt_to_text(text) | |
| sections = markdown_to_sections(text) | |
| first_heading = re.search(r"^#\s+(.+)$", text, re.M) | |
| title = first_heading.group(1).strip() if first_heading else stem | |
| if sections and sections[0][0] == title: | |
| sections[0] = (None, sections[0][1]) | |
| return FeedEntry(title=title, url=url, published_at=None, html="", sections=sections) | |