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()