Spaces:
Runtime error
Runtime error
Commit ·
0c11338
1
Parent(s): 1960fbf
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- .env.example +4 -0
- backend/app/config.py +3 -0
- backend/app/pipeline/ingest.py +166 -3
- backend/app/services/firecrawl.py +40 -0
- backend/app/worker.py +2 -0
- backend/tests/test_buildout.py +53 -0
- deploy/env.prod.example +1 -0
- frontend/seo/sitemap.ts +1 -0
- frontend/src/App.tsx +2 -0
- frontend/src/components/PublicChrome.tsx +14 -3
- frontend/src/content/missionPosts.ts +10 -9
- frontend/src/pages/Blog.tsx +1 -1
- frontend/src/pages/Landing.tsx +6 -1
- frontend/src/pages/NotebookLMAlternative.tsx +242 -0
- frontend/src/pages/Sources.tsx +1 -1
- frontend/src/styles/blog.css +86 -0
- frontend/tsconfig.tsbuildinfo +1 -1
.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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 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">
|
| 63 |
-
<a href="https://blog2video.app" target="_blank" rel="
|
| 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:
|
| 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 |
-
"
|
| 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: "
|
| 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 |
-
"
|
| 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
|
| 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 |
-
"
|
| 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
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"}
|