Arslan1997 commited on
Commit
0c11338
·
1 Parent(s): 1960fbf

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
.env.example CHANGED
@@ -80,6 +80,10 @@ TYPESAFE_API_KEY=
80
  TYPESAFE_MODEL=jev-latest
81
  TYPESAFE_URL=https://api.typesafe.ai
82
 
 
 
 
 
83
  # ---- Research corpus (posts as markdown files; the agent greps and reads them, no vector index)
84
  CORPUS_CACHE_DIR=.corpus-cache
85
  RESEARCH_MAX_STEPS=10
 
80
  TYPESAFE_MODEL=jev-latest
81
  TYPESAFE_URL=https://api.typesafe.ai
82
 
83
+ # Firecrawl (firecrawl.dev): lets Connect read sites with no RSS feed, including JavaScript sites
84
+ # (Next.js, Framer, Webflow), and makes single URL imports reliable. Empty = plain HTTP fetching only.
85
+ FIRECRAWL_API_KEY=
86
+
87
  # ---- Research corpus (posts as markdown files; the agent greps and reads them, no vector index)
88
  CORPUS_CACHE_DIR=.corpus-cache
89
  RESEARCH_MAX_STEPS=10
backend/app/config.py CHANGED
@@ -70,6 +70,9 @@ class Settings(BaseSettings):
70
  typesafe_api_key: str = ""
71
  typesafe_model: str = "jev-latest"
72
  typesafe_url: str = "https://api.typesafe.ai"
 
 
 
73
  llm_api_base: str = "https://api.z.ai/api/paas/v4"
74
  llm_api_key: str = ""
75
  llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
 
70
  typesafe_api_key: str = ""
71
  typesafe_model: str = "jev-latest"
72
  typesafe_url: str = "https://api.typesafe.ai"
73
+ # Firecrawl: crawls sites without an RSS feed and renders JavaScript pages. Empty = plain HTTP only.
74
+ firecrawl_api_key: str = ""
75
+ firecrawl_url: str = "https://api.firecrawl.dev"
76
  llm_api_base: str = "https://api.z.ai/api/paas/v4"
77
  llm_api_key: str = ""
78
  llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
backend/app/pipeline/ingest.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  Three ways in, one pipeline out:
4
  - feeds (Substack, Ghost, Medium, any RSS/Atom), with Substack's archive API for posts past the feed
 
5
  - one article by URL
6
  - an uploaded file (markdown, text, HTML or PDF)
7
  All of them end in `store_entries`, which writes documents, raw HTML and corpus files.
@@ -13,6 +14,7 @@ import logging
13
  import re
14
  import time
15
  import uuid
 
16
  from dataclasses import dataclass
17
  from datetime import UTC, datetime
18
  from time import mktime
@@ -26,6 +28,7 @@ from sqlalchemy.orm import Session
26
 
27
  from app.corpus import INDEX, Corpus, index_line, post_path, render_post, slugify
28
  from app.models import Document, Job, Source
 
29
  from app.services.jobs import update_job
30
  from app.services.storage import keys, storage
31
 
@@ -34,6 +37,7 @@ log = logging.getLogger(__name__)
34
  _DROP = re.compile(r"(subscribe|share this post|leave a comment|thanks for reading)", re.I)
35
  UA = {"User-Agent": "Mozilla/5.0 (compatible; NotestackBot/0.2; +https://notestack.ai)"}
36
  IMPORTS_FEED = "notestack:imports"
 
37
 
38
 
39
  class FeedNotFound(ValueError):
@@ -83,8 +87,27 @@ class DiscoveredFeed:
83
  site_url: str
84
 
85
 
 
 
 
 
 
 
 
 
86
  def discover_feed(url: str) -> DiscoveredFeed:
87
- """Find a working feed for whatever the writer pasted, or raise FeedNotFound with a clear reason."""
 
 
 
 
 
 
 
 
 
 
 
88
  url = _with_scheme(url)
89
  guess, platform = normalize_feed_url(url)
90
  parsed_url = urlparse(url)
@@ -92,12 +115,15 @@ def discover_feed(url: str) -> DiscoveredFeed:
92
  candidates = [guess, url, f"{site}/feed", f"{site}/rss", f"{site}/rss.xml", f"{site}/feed.xml", f"{site}/atom.xml"]
93
  seen: set[str] = set()
94
  last: httpx.Response | None = None
 
95
  for candidate in candidates:
96
  if candidate in seen:
97
  continue
98
  seen.add(candidate)
99
  parsed, resp = _parse_feed(candidate)
100
  last = resp or last
 
 
101
  if parsed:
102
  return DiscoveredFeed(candidate, _platform(candidate, parsed, platform), parsed.feed.get("title"), site)
103
  if resp is not None and resp.status_code < 400 and "html" in resp.headers.get("content-type", ""):
@@ -111,6 +137,11 @@ def discover_feed(url: str) -> DiscoveredFeed:
111
  found, _ = _parse_feed(href)
112
  if found:
113
  return DiscoveredFeed(href, _platform(href, found, platform), found.feed.get("title"), site)
 
 
 
 
 
114
  if last is not None and last.status_code == 404:
115
  raise FeedNotFound(f"{parsed_url.netloc} returned 404. Check the address in your browser.")
116
  raise FeedNotFound("We couldn't find a feed at that address.")
@@ -208,9 +239,45 @@ class FeedEntry:
208
  sections: list[tuple[str | None, str]] | None = None # set when the source is not HTML
209
 
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  def extract_article(url: str) -> FeedEntry:
212
- """Fetch one web page and keep the article body."""
213
  url = _with_scheme(url)
 
 
 
 
 
214
  resp = httpx.get(url, follow_redirects=True, timeout=30, headers=UA)
215
  if resp.status_code >= 400:
216
  raise FeedNotFound(f"That page returned {resp.status_code}.")
@@ -253,6 +320,92 @@ def fetch_feed(feed_url: str, limit: int) -> tuple[str | None, list[FeedEntry]]:
253
  return parsed.feed.get("title"), entries
254
 
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None) -> list[FeedEntry]:
257
  """Posts beyond the ~20 the RSS feed carries, through Substack's public archive API."""
258
  out: list[FeedEntry] = []
@@ -383,7 +536,17 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
383
  source.sync_status = "syncing"
384
  update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
385
 
386
- title, entries = fetch_feed(source.feed_url, max_posts)
 
 
 
 
 
 
 
 
 
 
387
  source.title = source.title or title
388
  if source.platform == "substack" and len(entries) < max_posts:
389
  site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
 
2
 
3
  Three ways in, one pipeline out:
4
  - feeds (Substack, Ghost, Medium, any RSS/Atom), with Substack's archive API for posts past the feed
5
+ - sites with no feed, crawled through their sitemap and links (Firecrawl renders JavaScript sites)
6
  - one article by URL
7
  - an uploaded file (markdown, text, HTML or PDF)
8
  All of them end in `store_entries`, which writes documents, raw HTML and corpus files.
 
14
  import re
15
  import time
16
  import uuid
17
+ from concurrent.futures import ThreadPoolExecutor
18
  from dataclasses import dataclass
19
  from datetime import UTC, datetime
20
  from time import mktime
 
28
 
29
  from app.corpus import INDEX, Corpus, index_line, post_path, render_post, slugify
30
  from app.models import Document, Job, Source
31
+ from app.services import firecrawl
32
  from app.services.jobs import update_job
33
  from app.services.storage import keys, storage
34
 
 
37
  _DROP = re.compile(r"(subscribe|share this post|leave a comment|thanks for reading)", re.I)
38
  UA = {"User-Agent": "Mozilla/5.0 (compatible; NotestackBot/0.2; +https://notestack.ai)"}
39
  IMPORTS_FEED = "notestack:imports"
40
+ SITE_PREFIX = "site:" # feed_url of a source crawled as a website instead of read from a feed
41
 
42
 
43
  class FeedNotFound(ValueError):
 
87
  site_url: str
88
 
89
 
90
+ _FEED_SUFFIX = re.compile(r"/(feed|rss|atom)(\.xml)?/?$|/[^/]+\.xml$", re.I)
91
+
92
+
93
+ def _as_site(url: str, title: str | None, site: str) -> DiscoveredFeed:
94
+ start = _FEED_SUFFIX.sub("", url).rstrip("/") or site
95
+ return DiscoveredFeed(f"{SITE_PREFIX}{start}", "website", title, site)
96
+
97
+
98
  def discover_feed(url: str) -> DiscoveredFeed:
99
+ """Pick how to read whatever the writer pasted, or raise FeedNotFound with a clear reason.
100
+
101
+ Substack keeps its feed plus archive API (complete and free). With Firecrawl configured, every
102
+ other site is crawled with it, since feeds often carry only excerpts or the latest few posts.
103
+ """
104
+ found = _discover_feed(url)
105
+ if firecrawl.enabled() and found.platform not in ("substack", "website"):
106
+ return _as_site(_with_scheme(url), found.title, found.site_url)
107
+ return found
108
+
109
+
110
+ def _discover_feed(url: str) -> DiscoveredFeed:
111
  url = _with_scheme(url)
112
  guess, platform = normalize_feed_url(url)
113
  parsed_url = urlparse(url)
 
115
  candidates = [guess, url, f"{site}/feed", f"{site}/rss", f"{site}/rss.xml", f"{site}/feed.xml", f"{site}/atom.xml"]
116
  seen: set[str] = set()
117
  last: httpx.Response | None = None
118
+ page: httpx.Response | None = None # the pasted address, when it is a working web page
119
  for candidate in candidates:
120
  if candidate in seen:
121
  continue
122
  seen.add(candidate)
123
  parsed, resp = _parse_feed(candidate)
124
  last = resp or last
125
+ if candidate == url and resp is not None and resp.status_code < 400:
126
+ page = resp
127
  if parsed:
128
  return DiscoveredFeed(candidate, _platform(candidate, parsed, platform), parsed.feed.get("title"), site)
129
  if resp is not None and resp.status_code < 400 and "html" in resp.headers.get("content-type", ""):
 
137
  found, _ = _parse_feed(href)
138
  if found:
139
  return DiscoveredFeed(href, _platform(href, found, platform), found.feed.get("title"), site)
140
+ if page is not None and "html" in page.headers.get("content-type", ""):
141
+ # No feed, but the site is up: crawl it as a website instead.
142
+ soup = BeautifulSoup(page.text, "html.parser")
143
+ title = _meta(soup, "og:site_name", "og:title") or (soup.title.get_text(strip=True) if soup.title else None)
144
+ return _as_site(url, title, site)
145
  if last is not None and last.status_code == 404:
146
  raise FeedNotFound(f"{parsed_url.netloc} returned 404. Check the address in your browser.")
147
  raise FeedNotFound("We couldn't find a feed at that address.")
 
239
  sections: list[tuple[str | None, str]] | None = None # set when the source is not HTML
240
 
241
 
242
+ _MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
243
+ _MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
244
+
245
+
246
+ def _clean_markdown(md: str) -> str:
247
+ """Firecrawl markdown to plain prose: drop images, keep link text."""
248
+ return _MD_LINK.sub(r"\1", _MD_IMAGE.sub("", md))
249
+
250
+
251
+ def _firecrawl_entry(url: str) -> FeedEntry:
252
+ data = firecrawl.scrape(url)
253
+ meta = data.get("metadata") or {}
254
+ text = _clean_markdown(data.get("markdown") or "")
255
+ heading = re.search(r"^#\s+(.+)$", text, re.M)
256
+ title = meta.get("ogTitle") or meta.get("title") or (heading.group(1).strip() if heading else url)
257
+ if isinstance(title, list):
258
+ title = title[0]
259
+ sections = markdown_to_sections(text)
260
+ if sections and sections[0][0] and heading and sections[0][0] == heading.group(1).strip():
261
+ sections[0] = (None, sections[0][1])
262
+ if not sections or sum(len(t) for _, t in sections) < 200:
263
+ raise FeedNotFound("We could not find article text on that page.")
264
+ published = None
265
+ for key in ("publishedTime", "article:published_time", "datePublished", "og:published_time", "modifiedTime"):
266
+ value = meta.get(key)
267
+ if published := _parse_date(value[0] if isinstance(value, list) else value):
268
+ break
269
+ final = meta.get("sourceURL") or meta.get("url") or url
270
+ return FeedEntry(title=str(title)[:1000], url=final, published_at=published, html="", sections=sections)
271
+
272
+
273
  def extract_article(url: str) -> FeedEntry:
274
+ """Fetch one web page and keep the article body. Firecrawl first when configured (it renders JS)."""
275
  url = _with_scheme(url)
276
+ if firecrawl.enabled():
277
+ try:
278
+ return _firecrawl_entry(url)
279
+ except (firecrawl.FirecrawlError, httpx.HTTPError, FeedNotFound):
280
+ log.warning("firecrawl scrape failed for %s, falling back to plain HTTP", url, exc_info=True)
281
  resp = httpx.get(url, follow_redirects=True, timeout=30, headers=UA)
282
  if resp.status_code >= 400:
283
  raise FeedNotFound(f"That page returned {resp.status_code}.")
 
320
  return parsed.feed.get("title"), entries
321
 
322
 
323
+ _NOT_POSTS = re.compile(
324
+ r"/(tag|tags|category|categories|author|authors|page|search|login|signin|signup|register|account|cart|"
325
+ r"privacy|terms|legal|cookies?|about|contact|pricing|careers|jobs|feed|rss|wp-admin|wp-json|cdn-cgi|_next)(/|$)",
326
+ re.I,
327
+ )
328
+
329
+
330
+ def _post_urls(links: list[str], start: str, limit: int) -> list[str]:
331
+ """Likely article pages among a site's links: same site, not the index itself, not utility pages."""
332
+ base = urlparse(start)
333
+ host = base.netloc.lower().removeprefix("www.")
334
+ prefix = base.path.rstrip("/")
335
+ out: list[str] = []
336
+ seen: set[str] = set()
337
+ for link in links:
338
+ p = urlparse(link)
339
+ path = p.path.rstrip("/")
340
+ if p.scheme not in ("http", "https") or p.netloc.lower().removeprefix("www.") != host:
341
+ continue
342
+ if not path or path == prefix or _NOT_POSTS.search(path + "/"):
343
+ continue
344
+ if re.search(r"\.(?!html?$)[a-z0-9]{2,5}$", path, re.I): # files: images, pdf, xml
345
+ continue
346
+ clean = f"{p.scheme}://{p.netloc}{path}"
347
+ if clean not in seen:
348
+ seen.add(clean)
349
+ out.append(clean)
350
+ # Pasted a blog index like /blog: keep what lives under it when anything does.
351
+ if prefix:
352
+ under = [u for u in out if urlparse(u).path.startswith(prefix + "/")]
353
+ out = under or out
354
+ return out[:limit]
355
+
356
+
357
+ def _plain_links(url: str) -> list[str]:
358
+ """Without Firecrawl: the sitemap plus links in the page HTML (misses posts rendered by JavaScript)."""
359
+ parsed = urlparse(url)
360
+ links: list[str] = []
361
+ for sitemap in (f"{parsed.scheme}://{parsed.netloc}/sitemap.xml", f"{parsed.scheme}://{parsed.netloc}/sitemap_index.xml"):
362
+ try:
363
+ resp = httpx.get(sitemap, follow_redirects=True, timeout=20, headers=UA)
364
+ except httpx.HTTPError:
365
+ continue
366
+ if resp.status_code < 400:
367
+ links += re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", resp.text)
368
+ try:
369
+ resp = httpx.get(url, follow_redirects=True, timeout=20, headers=UA)
370
+ if resp.status_code < 400:
371
+ soup = BeautifulSoup(resp.text, "html.parser")
372
+ links += [urljoin(str(resp.url), a["href"]) for a in soup.find_all("a", href=True)]
373
+ except httpx.HTTPError:
374
+ pass
375
+ return links
376
+
377
+
378
+ def crawl_site(url: str, limit: int, on_found=None, on_post=None) -> list[FeedEntry]:
379
+ """Find the posts on a site with no feed and read each one."""
380
+ if firecrawl.enabled():
381
+ links = firecrawl.map_site(url, max(limit * 4, 100))
382
+ else:
383
+ links = _plain_links(url)
384
+ urls = _post_urls(links, url, limit)
385
+ if not urls:
386
+ hint = "" if firecrawl.enabled() else " If the site builds its pages with JavaScript, set FIRECRAWL_API_KEY."
387
+ raise FeedNotFound(f"We could not find any posts on {urlparse(url).netloc}.{hint}")
388
+ if on_found:
389
+ on_found(len(urls))
390
+
391
+ def read(u: str) -> FeedEntry | None:
392
+ try:
393
+ return extract_article(u)
394
+ except (FeedNotFound, httpx.HTTPError, firecrawl.FirecrawlError):
395
+ return None
396
+
397
+ entries: list[FeedEntry] = []
398
+ with ThreadPoolExecutor(max_workers=4) as pool:
399
+ for i, entry in enumerate(pool.map(read, urls)):
400
+ if entry:
401
+ entries.append(entry)
402
+ if on_post:
403
+ on_post(i + 1, len(urls))
404
+ if not entries:
405
+ raise FeedNotFound(f"We found {len(urls)} pages on {urlparse(url).netloc} but none had article text.")
406
+ return entries
407
+
408
+
409
  def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None) -> list[FeedEntry]:
410
  """Posts beyond the ~20 the RSS feed carries, through Substack's public archive API."""
411
  out: list[FeedEntry] = []
 
536
  source.sync_status = "syncing"
537
  update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
538
 
539
+ if source.feed_url.startswith(SITE_PREFIX):
540
+ update_job(db, job, message="Mapping your site")
541
+ entries = crawl_site(
542
+ source.feed_url.removeprefix(SITE_PREFIX), max_posts,
543
+ on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
544
+ on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
545
+ message=f"Read {done} of {total} pages"),
546
+ )
547
+ title = None
548
+ else:
549
+ title, entries = fetch_feed(source.feed_url, max_posts)
550
  source.title = source.title or title
551
  if source.platform == "substack" and len(entries) < max_posts:
552
  site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
backend/app/services/firecrawl.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Firecrawl: renders JavaScript sites, so posts on sites without a feed (Next.js, Framer, Webflow) still come in."""
2
+
3
+ import httpx
4
+
5
+ from app.config import settings
6
+
7
+
8
+ class FirecrawlError(RuntimeError):
9
+ pass
10
+
11
+
12
+ def enabled() -> bool:
13
+ return bool(settings.firecrawl_api_key)
14
+
15
+
16
+ def _post(path: str, body: dict, timeout: float = 90) -> dict:
17
+ resp = httpx.post(
18
+ f"{settings.firecrawl_url.rstrip('/')}{path}",
19
+ json=body,
20
+ headers={"Authorization": f"Bearer {settings.firecrawl_api_key}"},
21
+ timeout=timeout,
22
+ )
23
+ if resp.status_code >= 400:
24
+ raise FirecrawlError(f"Firecrawl returned {resp.status_code}: {resp.text[:200]}")
25
+ data = resp.json()
26
+ if data.get("success") is False:
27
+ raise FirecrawlError(str(data.get("error") or "Firecrawl request failed")[:200])
28
+ return data
29
+
30
+
31
+ def map_site(url: str, limit: int) -> list[str]:
32
+ """Every URL Firecrawl can find on the site (sitemap plus links on rendered pages)."""
33
+ data = _post("/v2/map", {"url": url, "limit": limit, "sitemap": "include"})
34
+ return [link if isinstance(link, str) else link.get("url", "") for link in data.get("links") or []]
35
+
36
+
37
+ def scrape(url: str) -> dict:
38
+ """Main content of one page as markdown, plus page metadata."""
39
+ data = _post("/v2/scrape", {"url": url, "formats": ["markdown"], "onlyMainContent": True})
40
+ return data.get("data") or {}
backend/app/worker.py CHANGED
@@ -87,6 +87,8 @@ def handle_ingest(db: Session, job: Job):
87
  db.commit()
88
  if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (401, 403, 404, 410):
89
  raise PermanentJobError(f"The feed returned {exc.response.status_code}.") from exc
 
 
90
  raise
91
  _after_import(db, job, source.workspace_id, result.get("changed", []))
92
  return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
 
87
  db.commit()
88
  if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (401, 403, 404, 410):
89
  raise PermanentJobError(f"The feed returned {exc.response.status_code}.") from exc
90
+ if isinstance(exc, FeedNotFound):
91
+ raise PermanentJobError(str(exc)) from exc
92
  raise
93
  _after_import(db, job, source.workspace_id, result.get("changed", []))
94
  return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
backend/tests/test_buildout.py CHANGED
@@ -139,6 +139,59 @@ def test_connect_rejects_missing_feed(client, auth, monkeypatch):
139
  assert r.json()["detail"]["code"] == "feed_not_found"
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  def test_connect_ingest_read_and_delete(client, auth, run_jobs, feed, db_session):
143
  source = connect(client, auth, run_jobs)
144
  assert source["title"] == "Ada Writes"
 
139
  assert r.json()["detail"]["code"] == "feed_not_found"
140
 
141
 
142
+ ARTICLE = "<html><head><title>{t}</title></head><body><article><h1>{t}</h1><p>{body}</p></article></body></html>"
143
+
144
+
145
+ def test_connect_site_without_feed_crawls_posts(client, auth, run_jobs, monkeypatch):
146
+ body = "A long paragraph about writing in public. " * 10
147
+ monkeypatch.setattr(httpx, "get", fake_web({
148
+ "https://site.example.com/blog/first": FakeResponse(200, ARTICLE.format(t="First", body=body), "text/html"),
149
+ "https://site.example.com/blog/second": FakeResponse(200, ARTICLE.format(t="Second", body=body), "text/html"),
150
+ "https://site.example.com/blog": FakeResponse(200, (
151
+ '<html><head><title>Site Blog</title></head><body><a href="/blog/first">1</a>'
152
+ '<a href="/blog/second">2</a><a href="/about">About</a><a href="/logo.png">x</a></body></html>'
153
+ ), "text/html"),
154
+ }))
155
+ r = client.post("/api/sources", json={"url": "https://site.example.com/blog"}, headers=auth)
156
+ assert r.status_code == 200, r.text
157
+ assert r.json()["source"]["platform"] == "website"
158
+ assert r.json()["source"]["feed_url"] == "site:https://site.example.com/blog"
159
+ jobs = run_jobs()
160
+ assert jobs[0].status == "done", jobs[0].error
161
+ assert {d["title"] for d in docs(client, auth)} == {"First", "Second"}
162
+
163
+
164
+ def test_crawl_uses_firecrawl_when_configured(monkeypatch):
165
+ from app.pipeline import ingest
166
+ from app.services import firecrawl
167
+
168
+ monkeypatch.setattr(firecrawl, "enabled", lambda: True)
169
+ monkeypatch.setattr(firecrawl, "map_site", lambda url, limit: [
170
+ "https://js.example.com/", "https://js.example.com/blog/rendered-post", "https://js.example.com/privacy",
171
+ ])
172
+ text = "Rendered by JavaScript, still readable. " * 10
173
+ monkeypatch.setattr(firecrawl, "scrape", lambda url: {
174
+ "markdown": f"# Rendered Post\n\n{text}\n\n![hero](https://x/y.png) See [the docs](https://x).",
175
+ "metadata": {"title": "Rendered Post", "sourceURL": url, "publishedTime": "2026-05-01T10:00:00Z"},
176
+ })
177
+ entries = ingest.crawl_site("https://js.example.com", 10)
178
+ assert [e.title for e in entries] == ["Rendered Post"]
179
+ assert entries[0].published_at.year == 2026
180
+ body = entries[0].sections[0][1]
181
+ assert "See the docs." in body and "png" not in body
182
+
183
+
184
+ def test_firecrawl_is_main_path_except_substack(feed, monkeypatch):
185
+ from app.pipeline import ingest
186
+ from app.services import firecrawl
187
+
188
+ monkeypatch.setattr(firecrawl, "enabled", lambda: True)
189
+ found = ingest.discover_feed("https://ada.example.com/feed")
190
+ assert (found.platform, found.feed_url, found.title) == ("website", "site:https://ada.example.com", "Ada Writes")
191
+ monkeypatch.setattr(httpx, "get", fake_web({"https://ada.substack.com/feed": FakeResponse(200, FEED)}))
192
+ assert ingest.discover_feed("ada.substack.com").platform == "substack"
193
+
194
+
195
  def test_connect_ingest_read_and_delete(client, auth, run_jobs, feed, db_session):
196
  source = connect(client, auth, run_jobs)
197
  assert source["title"] == "Ada Writes"
deploy/env.prod.example CHANGED
@@ -51,6 +51,7 @@ R2_BUCKET=notestack-assets
51
  LLM_API_KEY=<z.ai key>
52
  ELEVENLABS_API_KEY=<elevenlabs key>
53
  TYPESAFE_API_KEY=
 
54
 
55
  # ---- Renderer (only used when started with --profile render)
56
  RENDERER_URL=http://renderer:3100
 
51
  LLM_API_KEY=<z.ai key>
52
  ELEVENLABS_API_KEY=<elevenlabs key>
53
  TYPESAFE_API_KEY=
54
+ FIRECRAWL_API_KEY=
55
 
56
  # ---- Renderer (only used when started with --profile render)
57
  RENDERER_URL=http://renderer:3100
frontend/seo/sitemap.ts CHANGED
@@ -10,6 +10,7 @@ import { blogPosts } from "../src/content/blogPosts";
10
 
11
  const STATIC_PAGES: { path: string; priority: string; changefreq: string }[] = [
12
  { path: "/", priority: "1.0", changefreq: "weekly" },
 
13
  { path: "/pricing", priority: "0.8", changefreq: "monthly" },
14
  { path: "/blogs", priority: "0.9", changefreq: "weekly" },
15
  ];
 
10
 
11
  const STATIC_PAGES: { path: string; priority: string; changefreq: string }[] = [
12
  { path: "/", priority: "1.0", changefreq: "weekly" },
13
+ { path: "/notebooklm-alternative", priority: "0.9", changefreq: "monthly" },
14
  { path: "/pricing", priority: "0.8", changefreq: "monthly" },
15
  { path: "/blogs", priority: "0.9", changefreq: "weekly" },
16
  ];
frontend/src/App.tsx CHANGED
@@ -12,6 +12,7 @@ import Launchpad from "./pages/Launchpad";
12
  import LaunchKit from "./pages/LaunchKit";
13
  import MissionControl from "./pages/MissionControl";
14
  import Notebooks from "./pages/Notebooks";
 
15
  import NotebookView from "./pages/NotebookView";
16
  import Resurface from "./pages/Resurface";
17
  import Settings from "./pages/Settings";
@@ -41,6 +42,7 @@ export default function App() {
41
  <Routes>
42
  <Route path="/" element={<Landing />} />
43
  <Route path="/pricing" element={<PricingPage />} />
 
44
  <Route path="/blogs" element={<Blog />} />
45
  <Route path="/blogs/:slug" element={<BlogPostPage />} />
46
  <Route path="/auth" element={<AuthPage />} />
 
12
  import LaunchKit from "./pages/LaunchKit";
13
  import MissionControl from "./pages/MissionControl";
14
  import Notebooks from "./pages/Notebooks";
15
+ import NotebookLMAlternative from "./pages/NotebookLMAlternative";
16
  import NotebookView from "./pages/NotebookView";
17
  import Resurface from "./pages/Resurface";
18
  import Settings from "./pages/Settings";
 
42
  <Routes>
43
  <Route path="/" element={<Landing />} />
44
  <Route path="/pricing" element={<PricingPage />} />
45
+ <Route path="/notebooklm-alternative" element={<NotebookLMAlternative />} />
46
  <Route path="/blogs" element={<Blog />} />
47
  <Route path="/blogs/:slug" element={<BlogPostPage />} />
48
  <Route path="/auth" element={<AuthPage />} />
frontend/src/components/PublicChrome.tsx CHANGED
@@ -47,7 +47,9 @@ export function PublicFooter() {
47
  <div>
48
  <p className="eyebrow">Product</p>
49
  <Link to="/#features">Features</Link>
 
50
  <Link to="/pricing">Pricing</Link>
 
51
  <Link to="/auth">Sign in</Link>
52
  </div>
53
  <div>
@@ -59,10 +61,19 @@ export function PublicFooter() {
59
  ))}
60
  </div>
61
  <div>
62
- <p className="eyebrow">Company</p>
63
- <a href="https://blog2video.app" target="_blank" rel="noreferrer">
64
- Blog2Video
 
 
 
 
 
 
65
  </a>
 
 
 
66
  <a href="mailto:hello@notestack.ai">Contact</a>
67
  </div>
68
  </div>
 
47
  <div>
48
  <p className="eyebrow">Product</p>
49
  <Link to="/#features">Features</Link>
50
+ <Link to="/notebooklm-alternative">NotebookLM alternative</Link>
51
  <Link to="/pricing">Pricing</Link>
52
+ <Link to="/blogs">Blog</Link>
53
  <Link to="/auth">Sign in</Link>
54
  </div>
55
  <div>
 
61
  ))}
62
  </div>
63
  <div>
64
+ <p className="eyebrow">More from Firebird</p>
65
+ <a href="https://blog2video.app" target="_blank" rel="noopener" title="Turn blogs, URLs and PDFs into videos">
66
+ Blog2Video: blog to video
67
+ </a>
68
+ <a href="https://pdf2vid.com" target="_blank" rel="noopener" title="Turn any PDF into a narrated video">
69
+ PDF2Video: PDF to video
70
+ </a>
71
+ <a href="https://bloghub.app" target="_blank" rel="noopener" title="Free blog and newsletter directory">
72
+ BlogHub: blog directory
73
  </a>
74
+ </div>
75
+ <div>
76
+ <p className="eyebrow">Company</p>
77
  <a href="mailto:hello@notestack.ai">Contact</a>
78
  </div>
79
  </div>
frontend/src/content/missionPosts.ts CHANGED
@@ -14,7 +14,7 @@ import type { BlogPost } from "./seoTypes";
14
  export const missionPosts: BlogPost[] = [
15
  {
16
  slug: "notebooklm-alternatives",
17
- title: "NotebookLM Alternatives in 2026: The Best Picks for Writers and Researchers",
18
  description:
19
  "An honest guide to NotebookLM alternatives: which tool to use for grounded research, open source privacy, audio, video and working with your own writing archive.",
20
  category: "Research",
@@ -30,7 +30,7 @@ export const missionPosts: BlogPost[] = [
30
  mission: {
31
  question: "What is the best NotebookLM alternative for my kind of work?",
32
  answer:
33
- "It depends on which part of NotebookLM you rely on. For grounded chat over your own writing, use a tool that cites exact lines (Notestack for writers, Khoj if you want open source and self hosted). For academic papers, Elicit or Consensus. For turning documents into video, [PDF2Video](https://pdf2vid.com) or [Blog2Video](https://blog2video.app).",
34
  },
35
  sections: [
36
  {
@@ -48,10 +48,11 @@ export const missionPosts: BlogPost[] = [
48
  callout: "The best alternative is the one that does your one job better, not the one that copies every feature.",
49
  },
50
  {
51
- heading: "For grounded answers over your own writing: Notestack",
52
  paragraphs: [
53
  "If the sources you care about are your own posts, a general notebook makes you upload files by hand and loses track of what you wrote when. Notestack connects to your Substack, Ghost, Medium or any RSS feed, keeps each post as its own file, and answers with citations to the exact lines it read.",
54
  "The difference shows up in the details. Answers cite line ranges, not whole documents, and anything the tool cannot support from your posts is dropped instead of guessed. The same archive feeds audio overviews, video and Launch Kits written in your voice.",
 
55
  ],
56
  bullets: [
57
  "Syncs a whole publication, including the back catalogue",
@@ -108,7 +109,7 @@ export const missionPosts: BlogPost[] = [
108
  {
109
  question: "Which NotebookLM alternative is best for writers?",
110
  answer:
111
- "One that connects to your publication instead of relying on manual uploads, cites exact lines, and turns answers into audio, video and posts in your voice. That is what Notestack is built for.",
112
  },
113
  ],
114
  distributionPlan: [
@@ -157,7 +158,7 @@ export const missionPosts: BlogPost[] = [
157
  {
158
  heading: "Stage two: read deeply with a grounded notebook",
159
  paragraphs: [
160
- "Once you have sources, you want an AI that answers only from them. This is where grounded notebooks shine: NotebookLM for uploaded documents, and Notestack when the sources are your own writing, synced straight from your publication.",
161
  "The best of these do more than summarize. They search with several phrasings, read around each hit, and cite the line range they used, so you can open the passage and check the context yourself.",
162
  ],
163
  },
@@ -254,7 +255,7 @@ export const missionPosts: BlogPost[] = [
254
  heading: "A memory of everything you have written",
255
  paragraphs: [
256
  "Writers with a long archive repeat themselves, contradict themselves and forget their best lines. A grounded notebook over your own posts fixes that. Ask what you have already argued about a topic and get the answer with citations to your past posts.",
257
- "Notestack builds this from your publication automatically, then maps the topics you keep returning to and the ones that went dormant. It is the research assistant that has read everything you ever published.",
258
  ],
259
  bullets: [
260
  "Find what you already said before you say it again",
@@ -361,7 +362,7 @@ export const missionPosts: BlogPost[] = [
361
  {
362
  heading: "What writers need that neither does",
363
  paragraphs: [
364
- "If your main source is your own archive, you need a notebook that syncs your publication, keeps each post intact and cites the exact lines. That is the gap Notestack fills, and our list of [NotebookLM alternatives](/blogs/notebooklm-alternatives) covers the other options.",
365
  "Writers also need to ship. Turn a PDF or report into a narrated explainer with [PDF2Video](https://pdf2vid.com), or turn a post into video with [Blog2Video](https://blog2video.app), so the research ends up in front of people.",
366
  ],
367
  },
@@ -395,7 +396,7 @@ export const missionPosts: BlogPost[] = [
395
  {
396
  question: "What is the best alternative to both for writers?",
397
  answer:
398
- "A grounded notebook built from your own publication, like Notestack, which cites exact lines and turns answers into audio, video and posts.",
399
  },
400
  ],
401
  distributionPlan: [
@@ -460,7 +461,7 @@ export const missionPosts: BlogPost[] = [
460
  {
461
  heading: "Setting one up in ten minutes",
462
  paragraphs: [
463
- "Paste your Substack or blog URL into Notestack and let it index your posts. Ask it what you have written about your favourite topic, open the topic map, and check which ideas you have not touched in a year.",
464
  "If you keep PDFs, research papers or slides alongside your writing, [PDF2Video](https://pdf2vid.com) can turn the best of them into narrated videos, so your second brain produces as well as remembers.",
465
  ],
466
  },
 
14
  export const missionPosts: BlogPost[] = [
15
  {
16
  slug: "notebooklm-alternatives",
17
+ title: "NotebookLM Alternatives in 2026: Why Notestack Is the Ultimate Pick for Writers",
18
  description:
19
  "An honest guide to NotebookLM alternatives: which tool to use for grounded research, open source privacy, audio, video and working with your own writing archive.",
20
  category: "Research",
 
30
  mission: {
31
  question: "What is the best NotebookLM alternative for my kind of work?",
32
  answer:
33
+ "For writers, bloggers and newsletters, Notestack is the ultimate NotebookLM alternative: it syncs your whole archive, cites the exact lines behind every answer, and turns your writing into audio, video and launch posts. If you need open source and self hosted, use Khoj. For academic papers, Elicit or Consensus. For turning documents into video, [PDF2Video](https://pdf2vid.com) or [Blog2Video](https://blog2video.app).",
34
  },
35
  sections: [
36
  {
 
48
  callout: "The best alternative is the one that does your one job better, not the one that copies every feature.",
49
  },
50
  {
51
+ heading: "The ultimate NotebookLM alternative for writers: Notestack",
52
  paragraphs: [
53
  "If the sources you care about are your own posts, a general notebook makes you upload files by hand and loses track of what you wrote when. Notestack connects to your Substack, Ghost, Medium or any RSS feed, keeps each post as its own file, and answers with citations to the exact lines it read.",
54
  "The difference shows up in the details. Answers cite line ranges, not whole documents, and anything the tool cannot support from your posts is dropped instead of guessed. The same archive feeds audio overviews, video and Launch Kits written in your voice.",
55
+ "That combination is why we call it the ultimate NotebookLM alternative for anyone who publishes. See the full [Notestack vs NotebookLM comparison](/notebooklm-alternative) for every difference, side by side.",
56
  ],
57
  bullets: [
58
  "Syncs a whole publication, including the back catalogue",
 
109
  {
110
  question: "Which NotebookLM alternative is best for writers?",
111
  answer:
112
+ "Notestack. It connects to your blog, newsletter or site (or your markdown files) instead of relying on manual uploads, cites exact lines, and turns answers into audio, video and posts in your voice. Compare them in [Notestack vs NotebookLM](/notebooklm-alternative).",
113
  },
114
  ],
115
  distributionPlan: [
 
158
  {
159
  heading: "Stage two: read deeply with a grounded notebook",
160
  paragraphs: [
161
+ "Once you have sources, you want an AI that answers only from them. This is where grounded notebooks shine: NotebookLM for uploaded documents, and Notestack, the ultimate [NotebookLM alternative](/notebooklm-alternative) when the sources are your own writing, synced straight from your blog, newsletter or site.",
162
  "The best of these do more than summarize. They search with several phrasings, read around each hit, and cite the line range they used, so you can open the passage and check the context yourself.",
163
  ],
164
  },
 
255
  heading: "A memory of everything you have written",
256
  paragraphs: [
257
  "Writers with a long archive repeat themselves, contradict themselves and forget their best lines. A grounded notebook over your own posts fixes that. Ask what you have already argued about a topic and get the answer with citations to your past posts.",
258
+ "Notestack builds this from your publication automatically, then maps the topics you keep returning to and the ones that went dormant. It is the research assistant that has read everything you ever published, and the ultimate [NotebookLM alternative](/notebooklm-alternative) for writers.",
259
  ],
260
  bullets: [
261
  "Find what you already said before you say it again",
 
362
  {
363
  heading: "What writers need that neither does",
364
  paragraphs: [
365
+ "If your main source is your own archive, you need a notebook that syncs your publication, keeps each post intact and cites the exact lines. That is the gap Notestack fills as the ultimate [NotebookLM alternative for writers](/notebooklm-alternative), and our list of [NotebookLM alternatives](/blogs/notebooklm-alternatives) covers the other options.",
366
  "Writers also need to ship. Turn a PDF or report into a narrated explainer with [PDF2Video](https://pdf2vid.com), or turn a post into video with [Blog2Video](https://blog2video.app), so the research ends up in front of people.",
367
  ],
368
  },
 
396
  {
397
  question: "What is the best alternative to both for writers?",
398
  answer:
399
+ "Notestack, the ultimate NotebookLM alternative for writers: a grounded notebook built from your own blog, newsletter, site or markdown that cites exact lines and turns answers into audio, video and posts.",
400
  },
401
  ],
402
  distributionPlan: [
 
461
  {
462
  heading: "Setting one up in ten minutes",
463
  paragraphs: [
464
+ "Paste your blog, newsletter or site URL into Notestack (the ultimate [NotebookLM alternative](/notebooklm-alternative) for writers), or upload your markdown, and let it index your posts. Ask it what you have written about your favourite topic, open the topic map, and check which ideas you have not touched in a year.",
465
  "If you keep PDFs, research papers or slides alongside your writing, [PDF2Video](https://pdf2vid.com) can turn the best of them into narrated videos, so your second brain produces as well as remembers.",
466
  ],
467
  },
frontend/src/pages/Blog.tsx CHANGED
@@ -6,7 +6,7 @@ import { blogPosts, getPost } from "../content/blogPosts";
6
  import type { BlogPost, Mission } from "../content/seoTypes";
7
  import "../styles/blog.css";
8
 
9
- function useMeta(title: string, description: string, canonical?: string) {
10
  useEffect(() => {
11
  document.title = title;
12
  const set = (selector: string, attr: string, value: string, create: () => HTMLElement) => {
 
6
  import type { BlogPost, Mission } from "../content/seoTypes";
7
  import "../styles/blog.css";
8
 
9
+ export function useMeta(title: string, description: string, canonical?: string) {
10
  useEffect(() => {
11
  document.title = title;
12
  const set = (selector: string, attr: string, value: string, create: () => HTMLElement) => {
frontend/src/pages/Landing.tsx CHANGED
@@ -168,7 +168,12 @@ export default function Landing() {
168
  <main>
169
  <section className="hero">
170
  <div className="container hero-inner">
171
- <p className="eyebrow">For writers, bloggers and newsletters</p>
 
 
 
 
 
172
  <h1 className="hero-title">
173
  Your knowledge,
174
  <br />
 
168
  <main>
169
  <section className="hero">
170
  <div className="container hero-inner">
171
+ <p className="eyebrow">
172
+ <Link to="/notebooklm-alternative" className="eyebrow-link">
173
+ The ultimate NotebookLM alternative
174
+ </Link>{" "}
175
+ for writers, bloggers and newsletters
176
+ </p>
177
  <h1 className="hero-title">
178
  Your knowledge,
179
  <br />
frontend/src/pages/NotebookLMAlternative.tsx ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link } from "react-router-dom";
2
+ import SkyCanvas from "../components/SkyCanvas";
3
+ import { PublicFooter, PublicNav } from "../components/PublicChrome";
4
+ import { useMeta } from "./Blog";
5
+ import "../styles/blog.css";
6
+
7
+ /** SEO landing page: "notebooklm alternative" (590 searches/mo in the US, keyword difficulty 0, Sep 2026). */
8
+
9
+ const ROWS: { feature: string; notestack: string; notebooklm: string }[] = [
10
+ {
11
+ feature: "Getting your writing in",
12
+ notestack: "Syncs any blog, newsletter or site feed (Substack, Ghost, WordPress, Medium, RSS), plus markdown, text, HTML and PDF uploads",
13
+ notebooklm: "Add sources one at a time: PDFs, Docs, web pages, videos",
14
+ },
15
+ {
16
+ feature: "Stays in sync",
17
+ notestack: "New posts arrive automatically on every sync",
18
+ notebooklm: "Re-add sources by hand when they change",
19
+ },
20
+ {
21
+ feature: "Citations",
22
+ notestack: "Exact line ranges, read back and verified before you see them",
23
+ notebooklm: "Passage level citations to the source",
24
+ },
25
+ {
26
+ feature: "When the answer is not in your sources",
27
+ notestack: "Says so instead of guessing; unverifiable claims are dropped",
28
+ notebooklm: "Grounded in sources, with passage citations",
29
+ },
30
+ {
31
+ feature: "Chat memory",
32
+ notestack: "Follow ups resolved from the conversation; off topic questions answered instantly",
33
+ notebooklm: "Conversation within a notebook",
34
+ },
35
+ {
36
+ feature: "Audio overviews",
37
+ notestack: "Two hosts, ElevenLabs voices, delivery controls, or your own consented voice clone",
38
+ notebooklm: "Two host audio overviews with Google voices",
39
+ },
40
+ {
41
+ feature: "Video",
42
+ notestack: "9:16 shorts, 16:9 explainers and 1:1 audiograms with captions and your brand",
43
+ notebooklm: "Video overviews",
44
+ },
45
+ {
46
+ feature: "Publishing",
47
+ notestack: "Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels, quote cards) and a scheduling calendar",
48
+ notebooklm: "Not a publishing tool",
49
+ },
50
+ {
51
+ feature: "Your voice",
52
+ notestack: "Voice profile learned from your posts, used for every draft",
53
+ notebooklm: "General purpose writing",
54
+ },
55
+ {
56
+ feature: "Seeing your archive",
57
+ notestack: "Topic map with rising and dormant topics, plus evergreen resurfacing",
58
+ notebooklm: "Mind maps per notebook",
59
+ },
60
+ ];
61
+
62
+ const FAQ = [
63
+ {
64
+ q: "What is the best NotebookLM alternative?",
65
+ a: "For writers, bloggers and newsletters, Notestack is the ultimate NotebookLM alternative: it syncs your whole archive, cites the exact lines behind every answer, and turns what you wrote into audio, video and launch posts. For academic papers, discovery tools like Elicit or Consensus pair well with it.",
66
+ },
67
+ {
68
+ q: "Is Notestack free?",
69
+ a: "Yes, there is a free plan to try it on your latest posts. Paid plans add more sources, more posts, more audio and video minutes and voice cloning.",
70
+ },
71
+ {
72
+ q: "Can Notestack make NotebookLM style audio overviews?",
73
+ a: "Yes. It writes a two host conversation grounded in your posts and voices it with ElevenLabs, with every line tied to the passage it came from. You can choose the voices or clone your own with consent.",
74
+ },
75
+ {
76
+ q: "What if my writing is not on Substack?",
77
+ a: "Notestack works with any blog, newsletter or site that has a feed, single article URLs, and uploaded markdown, text, HTML or PDF files.",
78
+ },
79
+ ];
80
+
81
+ export default function NotebookLMAlternative() {
82
+ useMeta(
83
+ "The Ultimate NotebookLM Alternative for Writers | Notestack",
84
+ "Notestack is the ultimate NotebookLM alternative for writers, bloggers and newsletters: sync your archive, get answers cited to the exact line, and turn your writing into audio, video and launch posts.",
85
+ );
86
+ const faqLd = {
87
+ "@context": "https://schema.org",
88
+ "@type": "FAQPage",
89
+ mainEntity: FAQ.map((f) => ({ "@type": "Question", name: f.q, acceptedAnswer: { "@type": "Answer", text: f.a } })),
90
+ };
91
+ const appLd = {
92
+ "@context": "https://schema.org",
93
+ "@type": "SoftwareApplication",
94
+ name: "Notestack",
95
+ applicationCategory: "BusinessApplication",
96
+ operatingSystem: "Web",
97
+ description: "The ultimate NotebookLM alternative for writers: a grounded research notebook built from your own archive.",
98
+ offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
99
+ };
100
+
101
+ return (
102
+ <div className="page">
103
+ <SkyCanvas intensity={0.7} travel />
104
+ <PublicNav />
105
+ <main className="container post mission alt-page">
106
+ <script type="application/ld+json">{JSON.stringify(faqLd)}</script>
107
+ <script type="application/ld+json">{JSON.stringify(appLd)}</script>
108
+
109
+ <header className="mission-head">
110
+ <p className="eyebrow">NotebookLM alternative</p>
111
+ <div className="transmission incoming">
112
+ <span className="mono tx-label">Incoming query</span>
113
+ <p className="tx-query">Is there a NotebookLM built for my own writing?</p>
114
+ </div>
115
+ <h1 className="post-title">The ultimate NotebookLM alternative for writers</h1>
116
+ <p className="post-lede muted">
117
+ NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or
118
+ markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.
119
+ </p>
120
+ <div className="alt-ctas">
121
+ <Link to="/auth?mode=signup" className="btn btn-primary">
122
+ Start free
123
+ </Link>
124
+ <a href="#compare" className="btn">
125
+ See the comparison
126
+ </a>
127
+ </div>
128
+ </header>
129
+
130
+ <section className="alt-section">
131
+ <p className="mono waypoint-label">Why writers switch</p>
132
+ <h2>Your archive is not a stack of uploads</h2>
133
+ <div className="alt-grid">
134
+ <div className="card">
135
+ <h3>It already knows your work</h3>
136
+ <p className="muted">
137
+ Paste your address once. Every post comes in, stays in sync, and keeps its date, so you can ask how your thinking changed
138
+ over the years.
139
+ </p>
140
+ </div>
141
+ <div className="card">
142
+ <h3>Every sentence, traceable</h3>
143
+ <p className="muted">
144
+ Answers cite line ranges that are read back from your files before you see them. If the posts do not support it, Notestack
145
+ says so.
146
+ </p>
147
+ </div>
148
+ <div className="card">
149
+ <h3>From research to launch</h3>
150
+ <p className="muted">
151
+ The same grounded archive writes audio overviews, videos, threads, LinkedIn posts, Notes and SEO packs, in your voice, and
152
+ schedules them.
153
+ </p>
154
+ </div>
155
+ </div>
156
+ </section>
157
+
158
+ <section id="compare" className="alt-section">
159
+ <p className="mono waypoint-label">Side by side</p>
160
+ <h2>Notestack vs NotebookLM</h2>
161
+ <div className="md-table alt-table">
162
+ <table>
163
+ <thead>
164
+ <tr>
165
+ <th scope="col"> </th>
166
+ <th scope="col">Notestack</th>
167
+ <th scope="col">NotebookLM</th>
168
+ </tr>
169
+ </thead>
170
+ <tbody>
171
+ {ROWS.map((r) => (
172
+ <tr key={r.feature}>
173
+ <th scope="row">{r.feature}</th>
174
+ <td>{r.notestack}</td>
175
+ <td className="muted">{r.notebooklm}</td>
176
+ </tr>
177
+ ))}
178
+ </tbody>
179
+ </table>
180
+ </div>
181
+ <p className="muted small">NotebookLM details reflect its public product at the time of writing and may change.</p>
182
+ </section>
183
+
184
+ <section className="alt-section">
185
+ <p className="mono waypoint-label">Honest take</p>
186
+ <h2>When NotebookLM is still the right call</h2>
187
+ <p className="muted">
188
+ If you are studying someone else's documents for a one off project, want a free tool from Google, or live in Google Drive,
189
+ NotebookLM is excellent. Notestack is the ultimate NotebookLM alternative when the sources are your own writing and the goal is
190
+ to reuse and publish it.
191
+ </p>
192
+ </section>
193
+
194
+ <section className="alt-section">
195
+ <p className="mono waypoint-label">The rest of the fleet</p>
196
+ <h2>Pair it with the right publishing tool</h2>
197
+ <div className="alt-grid">
198
+ <a className="card alt-sister" href="https://blog2video.app" target="_blank" rel="noopener">
199
+ <h3>Blog2Video</h3>
200
+ <p className="muted">Turn blog posts, URLs, PDFs and scripts into finished videos without an editor.</p>
201
+ <span className="mono small-link">blog2video.app</span>
202
+ </a>
203
+ <a className="card alt-sister" href="https://pdf2vid.com" target="_blank" rel="noopener">
204
+ <h3>PDF2Video</h3>
205
+ <p className="muted">Turn any PDF, report, paper or deck into a narrated video.</p>
206
+ <span className="mono small-link">pdf2vid.com</span>
207
+ </a>
208
+ <a className="card alt-sister" href="https://bloghub.app" target="_blank" rel="noopener">
209
+ <h3>BlogHub</h3>
210
+ <p className="muted">List your blog or newsletter in a free directory so new readers can find it.</p>
211
+ <span className="mono small-link">bloghub.app</span>
212
+ </a>
213
+ </div>
214
+ </section>
215
+
216
+ <section className="alt-section debrief">
217
+ <p className="mono waypoint-label">Mission debrief</p>
218
+ <h2>Questions</h2>
219
+ {FAQ.map((f) => (
220
+ <details key={f.q} className="faq">
221
+ <summary>{f.q}</summary>
222
+ <p className="muted">{f.a}</p>
223
+ </details>
224
+ ))}
225
+ <p className="muted">
226
+ Comparing more tools? Read our full guide to <Link to="/blogs/notebooklm-alternatives">NotebookLM alternatives</Link> and{" "}
227
+ <Link to="/blogs/notebooklm-vs-chatgpt">NotebookLM vs ChatGPT</Link>.
228
+ </p>
229
+ </section>
230
+
231
+ <aside className="card post-cta">
232
+ <h3>Put your knowledge in orbit</h3>
233
+ <p className="muted">Connect any blog, newsletter or site, or upload markdown, and ask your first question in minutes.</p>
234
+ <Link to="/auth?mode=signup" className="btn btn-primary">
235
+ Start free
236
+ </Link>
237
+ </aside>
238
+ </main>
239
+ <PublicFooter />
240
+ </div>
241
+ );
242
+ }
frontend/src/pages/Sources.tsx CHANGED
@@ -167,7 +167,7 @@ export default function Sources() {
167
  </header>
168
  {!s.is_imports && (
169
  <a className="mono muted small-link" href={s.site_url ?? s.feed_url} target="_blank" rel="noreferrer">
170
- {s.feed_url}
171
  </a>
172
  )}
173
  {job && (active || job.status === "failed") && <JobProgress job={job} compact />}
 
167
  </header>
168
  {!s.is_imports && (
169
  <a className="mono muted small-link" href={s.site_url ?? s.feed_url} target="_blank" rel="noreferrer">
170
+ {s.feed_url.replace(/^site:/, "")}
171
  </a>
172
  )}
173
  {job && (active || job.status === "failed") && <JobProgress job={job} compact />}
frontend/src/styles/blog.css CHANGED
@@ -345,3 +345,89 @@
345
  transition: none;
346
  }
347
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  transition: none;
346
  }
347
  }
348
+
349
+ /* NotebookLM alternative page */
350
+ .alt-page .mission-head {
351
+ max-width: 860px;
352
+ }
353
+
354
+ .alt-ctas {
355
+ display: flex;
356
+ flex-wrap: wrap;
357
+ gap: 10px;
358
+ }
359
+
360
+ .alt-section {
361
+ margin-top: 56px;
362
+ display: grid;
363
+ gap: 14px;
364
+ }
365
+
366
+ .alt-section h2 {
367
+ font-size: clamp(1.5rem, 3vw, 2rem);
368
+ }
369
+
370
+ .alt-grid {
371
+ display: grid;
372
+ gap: 14px;
373
+ }
374
+
375
+ @media (min-width: 820px) {
376
+ .alt-grid {
377
+ grid-template-columns: repeat(3, 1fr);
378
+ }
379
+ }
380
+
381
+ .alt-grid .card {
382
+ display: grid;
383
+ gap: 8px;
384
+ align-content: start;
385
+ }
386
+
387
+ .alt-sister {
388
+ text-decoration: none;
389
+ }
390
+
391
+ .alt-sister:hover {
392
+ border-color: var(--blue-60);
393
+ box-shadow: var(--glow-md);
394
+ }
395
+
396
+ .alt-table table {
397
+ width: 100%;
398
+ border-collapse: collapse;
399
+ font-size: 0.92rem;
400
+ }
401
+
402
+ .alt-table th,
403
+ .alt-table td {
404
+ padding: 12px 14px;
405
+ border-bottom: 1px solid var(--line);
406
+ text-align: left;
407
+ vertical-align: top;
408
+ }
409
+
410
+ .alt-table thead th {
411
+ font: 600 0.72rem var(--font-mono);
412
+ letter-spacing: 0.12em;
413
+ text-transform: uppercase;
414
+ color: var(--blue);
415
+ }
416
+
417
+ .alt-table tbody th {
418
+ font-weight: 600;
419
+ color: var(--white);
420
+ width: 22%;
421
+ }
422
+
423
+ .alt-table td:nth-child(2) {
424
+ color: var(--white);
425
+ background: linear-gradient(90deg, var(--blue-10), transparent);
426
+ }
427
+
428
+ .eyebrow-link {
429
+ color: var(--white);
430
+ text-decoration: underline;
431
+ text-decoration-color: var(--blue);
432
+ text-underline-offset: 4px;
433
+ }
frontend/tsconfig.tsbuildinfo CHANGED
@@ -1 +1 @@
1
- {"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
 
1
+ {"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}