diff --git a/fintick/assets/apple-touch-icon.png b/fintick/assets/apple-touch-icon.png
new file mode 100644
index 0000000..0859cdd
Binary files /dev/null and b/fintick/assets/apple-touch-icon.png differ
diff --git a/fintick/assets/favicon.svg b/fintick/assets/favicon.svg
new file mode 100644
index 0000000..d4f9140
--- /dev/null
+++ b/fintick/assets/favicon.svg
@@ -0,0 +1,6 @@
+
diff --git a/fintick/assets/og.png b/fintick/assets/og.png
new file mode 100644
index 0000000..9d7d456
Binary files /dev/null and b/fintick/assets/og.png differ
diff --git a/fintick/dashboard.py b/fintick/dashboard.py
index fd4bb7a..605d8ee 100644
--- a/fintick/dashboard.py
+++ b/fintick/dashboard.py
@@ -21,6 +21,14 @@
open_database,
)
+ASSET_DIR = Path(__file__).resolve().parent / "assets"
+ASSET_ROUTES = {
+ "/og.png": ("og.png", "image/png"),
+ "/favicon.svg": ("favicon.svg", "image/svg+xml"),
+ "/favicon.ico": ("favicon.svg", "image/svg+xml"),
+ "/apple-touch-icon.png": ("apple-touch-icon.png", "image/png"),
+}
+
DEFAULT_LIMIT = 100
MAX_LIMIT = 250
# 'unconfirmed' sinks to the bottom: it is breaking that the wire never caught up on.
@@ -138,13 +146,111 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str,
}
+SITE_ORIGIN = os.environ.get("FINTICK_SITE_ORIGIN", "https://fintick.fyi").rstrip("/")
+
+# Crawlable: the board itself. Not crawlable: the JSON API (no prose to index) and the
+# ?ops operator view, which is the same page plus telemetry and would read as duplicate
+# content. The canonical link handles ?ops for engines that ignore the query rule.
+ROBOTS_TXT = """User-agent: *
+Allow: /$
+Disallow: /api/
+Disallow: /*?ops
+
+Sitemap: {origin}/sitemap.xml
+"""
+
+SITEMAP_XML = """
+
+
+ {origin}/
+ hourly
+ 1.0
+
+
+"""
+
+# AEO: a plain-language brief for answer engines and browsing agents, which reward a
+# stated method and explicit limits over marketing copy.
+LLMS_TXT = """# FinTick
+
+> A live tape of financial events, scored by whether independent news has caught up yet.
+
+FinTick ingests one public financial stream, aggregates its posts into distinct events
+using a language model, extracts structured facts from each, then searches independent
+news sources to corroborate them.
+
+## How an event is classified
+
+- **breaking** — no independent source has reported it yet. The stream is ahead of the wire.
+- **unconfirmed** — was breaking, and the wire still had not corroborated it after the
+ configured window elapsed.
+- **developing** — partially corroborated.
+- **confirmed** — independently reported by one or more outlets.
+- **contradicted** — an independent source disputes it.
+
+## Method and limits
+
+- Aggregation performs the deduplication: repeated posts about one event merge into that
+ event and are retained as `seen N times` evidence rather than discarded.
+- Every ingested post is preserved by immutable URI and receives exactly one durable
+ outcome. A post belongs to at most one event.
+- Social posts are treated as stream signal, never as independent corroboration.
+- FinTick reports whether an event has been corroborated. It does not assert that an
+ event is true, and it is not investment advice.
+
+## Endpoints
+
+- `/` — the board.
+- `/api/feed` — the same events as JSON.
+
+## Source
+
+- https://github.com/msitarzewski/fintick
+"""
+
DASHBOARD_HTML = r'''
+
+
FinTick — The Edge Board
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
")[0]
+ graph = json.loads(block)["@graph"]
+ self.assertEqual({node["@type"] for node in graph}, {"WebSite", "WebApplication"})
+
+ def test_robots_points_at_sitemap_and_shields_api_and_ops(self) -> None:
+ status, headers, body = self._get("/robots.txt")
+ text = body.decode()
+ self.assertEqual(status, 200)
+ self.assertTrue(headers["Content-Type"].startswith("text/plain"))
+ self.assertIn("Disallow: /api/", text)
+ self.assertIn("Disallow: /*?ops", text)
+ self.assertIn("Sitemap: https://fintick.fyi/sitemap.xml", text)
+
+ def test_sitemap_is_well_formed_xml(self) -> None:
+ import xml.etree.ElementTree as ET
+
+ status, _, body = self._get("/sitemap.xml")
+ self.assertEqual(status, 200)
+ root = ET.fromstring(body.decode())
+ namespace = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
+ self.assertEqual(root.tag, f"{namespace}urlset")
+ locations = [url.findtext(f"{namespace}loc") for url in root]
+ self.assertEqual(locations, ["https://fintick.fyi/"])
+
+ def test_llms_txt_states_the_method_and_its_limits(self) -> None:
+ status, _, body = self._get("/llms.txt")
+ text = body.decode()
+ self.assertEqual(status, 200)
+ self.assertIn("# FinTick", text)
+ for status_name in ("breaking", "unconfirmed", "confirmed", "contradicted"):
+ self.assertIn(status_name, text)
+ self.assertIn("not investment advice", text)
+
+ def test_assets_are_served_with_types_and_cached(self) -> None:
+ for path, content_type in (
+ ("/og.png", "image/png"),
+ ("/favicon.svg", "image/svg+xml"),
+ ("/apple-touch-icon.png", "image/png"),
+ ):
+ status, headers, body = self._get(path)
+ self.assertEqual(status, 200, path)
+ self.assertEqual(headers["Content-Type"], content_type, path)
+ self.assertIn("max-age", headers["Cache-Control"], path)
+ self.assertTrue(body, f"{path} served empty")
+
+ def test_head_is_answered_for_scrapers(self) -> None:
+ # Social scrapers HEAD an og:image first; the base handler 501s without do_HEAD,
+ # which reads to them as a broken image.
+ for path in ("/", "/og.png"):
+ request = urllib.request.Request(self.base_url + path, method="HEAD")
+ with urllib.request.urlopen(request, timeout=2) as response:
+ self.assertEqual(response.status, 200, path)
+ self.assertEqual(response.read(), b"", f"{path} HEAD returned a body")
+ self.assertTrue(response.headers["Content-Length"], path)
+
+
if __name__ == "__main__":
unittest.main()