File size: 32,892 Bytes
34b0226
 
 
 
0c11338
34b0226
 
 
 
00a4363
 
34b0226
00a4363
 
34b0226
00a4363
0c11338
00a4363
 
 
34b0226
00a4363
 
 
 
 
 
 
94b5741
 
0c11338
94b5741
00a4363
 
 
 
 
34b0226
 
0c11338
00a4363
 
34b0226
 
 
 
 
 
 
 
00a4363
34b0226
 
 
 
 
 
00a4363
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c11338
 
 
 
 
 
 
 
34b0226
0c11338
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
 
 
 
 
0c11338
34b0226
 
 
 
 
 
0c11338
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
0c11338
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00a4363
 
34b0226
00a4363
 
9499163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00a4363
 
 
 
 
 
 
 
 
 
9499163
 
 
34b0226
 
 
 
 
 
 
 
 
9499163
34b0226
 
9499163
 
 
 
 
 
 
 
 
 
34b0226
9499163
 
 
 
34b0226
 
 
 
 
 
9499163
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00a4363
 
 
 
 
 
34b0226
 
 
9499163
 
0c11338
 
 
9499163
 
 
 
 
 
 
 
0c11338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
0c11338
34b0226
0c11338
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00a4363
 
 
34b0226
00a4363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c11338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b612dd5
 
 
 
 
 
 
 
 
0c11338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b612dd5
0c11338
 
b612dd5
0c11338
 
 
b612dd5
 
0c11338
 
 
 
 
b612dd5
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b612dd5
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94b5741
34b0226
 
94b5741
 
 
 
 
 
 
 
34b0226
94b5741
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
00a4363
34b0226
 
 
 
 
94b5741
 
00a4363
34b0226
94b5741
34b0226
 
00a4363
34b0226
 
00a4363
94b5741
00a4363
 
34b0226
 
 
 
fa8df4c
 
 
00a4363
 
 
94b5741
 
 
 
 
 
 
 
00a4363
 
 
34b0226
 
 
 
00a4363
34b0226
94f4eb8
34b0226
 
 
94b5741
 
 
00a4363
34b0226
00a4363
34b0226
 
 
94b5741
 
34b0226
 
 
 
b612dd5
 
 
fd0c922
94f4eb8
 
 
 
 
 
 
 
 
b612dd5
 
94f4eb8
 
 
 
 
 
 
 
 
 
 
 
b612dd5
 
 
94f4eb8
 
 
 
 
34b0226
 
fd0c922
66de9c8
 
 
 
 
 
 
 
34b0226
0c11338
 
 
fd0c922
66de9c8
 
 
 
0c11338
 
 
0bf4415
66de9c8
34b0226
0bf4415
34b0226
66de9c8
34b0226
 
0bf4415
66de9c8
 
 
b612dd5
34b0226
 
 
 
94f4eb8
 
0bf4415
34b0226
66de9c8
94f4eb8
66de9c8
94f4eb8
34b0226
94f4eb8
 
 
b612dd5
94f4eb8
 
 
94b5741
00a4363
 
 
 
94f4eb8
 
0bf4415
34b0226
 
0649f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
0649f1b
 
34b0226
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
"""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


@dataclass
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"![{alt}]({src})")
            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


@dataclass
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![{alt}]({src})\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)