Skip to main content

Add an is_mastodon_host() function for detecting Mastodon instances

ID
17390b5
date
2025-12-04 12:22:28+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
98013cb
message
Add an `is_mastodon_host()` function for detecting Mastodon instances
changed files
14 files, 482 additions, 5 deletions

Changed files

.gitattributes (0) → .gitattributes (142)

diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3845776
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,4 @@
+requirements.txt linguist-generated=true
+dev_requirements.txt linguist-generated=true
+
+tests/fixtures/cassettes/*.yml linguist-generated=true

CHANGELOG.md (745) → CHANGELOG.md (805)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ed49b5..2be0e1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # CHANGELOG
 
+## v8 - 2025-12-04
+
+Add the `is_mastodon_host()` function.
+
 ## v7 - 2025-12-03
 
 Add the `parse_tumblr_post_url()` function.

dev_requirements.in (56) → dev_requirements.in (82)

diff --git a/dev_requirements.in b/dev_requirements.in
index fa9d349..d43b3e6 100644
--- a/dev_requirements.in
+++ b/dev_requirements.in
@@ -4,4 +4,5 @@ build
 mypy
 pytest-cov
 ruff
+silver-nitrate[cassettes]
 twine

dev_requirements.txt (1829) → dev_requirements.txt (2272)

diff --git a/dev_requirements.txt b/dev_requirements.txt
index ca4b757..1182388 100644
--- a/dev_requirements.txt
+++ b/dev_requirements.txt
@@ -2,22 +2,35 @@
 #    uv pip compile dev_requirements.in --output-file dev_requirements.txt
 -e file:.
     # via -r dev_requirements.in
+anyio==4.12.0
+    # via httpx
 build==1.3.0
     # via -r dev_requirements.in
 certifi==2025.11.12
-    # via requests
+    # via
+    #   httpcore
+    #   httpx
+    #   requests
 charset-normalizer==3.4.4
     # via requests
 coverage==7.12.0
     # via pytest-cov
 docutils==0.22.3
     # via readme-renderer
+h11==0.16.0
+    # via httpcore
+httpcore==1.0.9
+    # via httpx
+httpx==0.28.1
+    # via alexwlchan-chives
 hyperlink==21.0.0
     # via alexwlchan-chives
 id==1.5.0
     # via twine
 idna==3.11
     # via
+    #   anyio
+    #   httpx
     #   hyperlink
     #   requests
 iniconfig==2.3.0
@@ -30,7 +43,7 @@ jaraco-functools==4.3.0
     # via keyring
 keyring==25.7.0
     # via twine
-librt==0.6.2
+librt==0.6.3
     # via mypy
 markdown-it-py==4.0.0
     # via rich
@@ -67,9 +80,16 @@ pymediainfo==7.0.1
 pyproject-hooks==1.2.0
     # via build
 pytest==9.0.1
-    # via pytest-cov
+    # via
+    #   pytest-cov
+    #   pytest-vcr
+    #   silver-nitrate
 pytest-cov==7.0.0
     # via -r dev_requirements.in
+pytest-vcr==1.0.2
+    # via silver-nitrate
+pyyaml==6.0.3
+    # via vcrpy
 readme-renderer==44.0
     # via twine
 requests==2.32.5
@@ -85,6 +105,8 @@ rich==14.2.0
     # via twine
 ruff==0.14.7
     # via -r dev_requirements.in
+silver-nitrate==1.8.1
+    # via -r dev_requirements.in
 twine==6.2.0
     # via -r dev_requirements.in
 typing-extensions==4.15.0
@@ -93,3 +115,7 @@ urllib3==2.5.0
     # via
     #   requests
     #   twine
+vcrpy==8.0.0
+    # via pytest-vcr
+wrapt==2.0.1
+    # via vcrpy

pyproject.toml (1278) → pyproject.toml (1287)

diff --git a/pyproject.toml b/pyproject.toml
index fdee3b2..a07a08b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ license = "MIT"
 
 [project.optional-dependencies]
 media = ["pymediainfo"]
-urls = ["hyperlink"]
+urls = ["httpx", "hyperlink"]
 
 [project.urls]
 "Homepage" = "https://github.com/alexwlchan/chives"

src/chives/__init__.py (390) → src/chives/__init__.py (390)

diff --git a/src/chives/__init__.py b/src/chives/__init__.py
index 2b7e989..b05a75c 100644
--- a/src/chives/__init__.py
+++ b/src/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
 
 """
 
-__version__ = "7"
+__version__ = "8"

src/chives/urls.py (1651) → src/chives/urls.py (3204)

diff --git a/src/chives/urls.py b/src/chives/urls.py
index 03a281d..7f161b0 100644
--- a/src/chives/urls.py
+++ b/src/chives/urls.py
@@ -3,6 +3,7 @@
 import re
 from typing import TypedDict
 
+import httpx
 import hyperlink
 
 
@@ -27,6 +28,64 @@ def clean_youtube_url(url: str) -> str:
     return str(u)
 
 
+def is_mastodon_host(hostname: str) -> bool:
+    """
+    Check if a hostname is a Mastodon server.
+    """
+    if hostname in {
+        "hachyderm.io",
+        "iconfactory.world",
+        "mas.to",
+        "mastodon.social",
+        "social.alexwlchan.net",
+    }:
+        return True
+
+    # See https://github.com/mastodon/mastodon/discussions/30547
+    #
+    # Fist we look at /.well-known/nodeinfo, which returns a response
+    # like this for Mastodon servers:
+    #
+    #     {
+    #       "links": [
+    #         {
+    #           "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
+    #           "href": "https://mastodon.online/nodeinfo/2.0"
+    #         }
+    #       ]
+    #     }
+    #
+    nodeinfo_resp = httpx.get(f"https://{hostname}/.well-known/nodeinfo")
+    try:
+        nodeinfo_resp.raise_for_status()
+    except Exception:
+        return False
+
+    # Then we try to call $.links[0].href, which should return something
+    # like:
+    #
+    #     {
+    #       "version": "2.0",
+    #       "software": {"name": "mastodon", "version": "4.5.2"},
+    #       …
+    #
+    try:
+        href = nodeinfo_resp.json()["links"][0]["href"]
+    except (KeyError, IndexError):  # pragma: no cover
+        return False
+
+    link_resp = httpx.get(href)
+    try:
+        link_resp.raise_for_status()
+    except Exception:  # pragma: no cover
+        return False
+
+    try:
+        return bool(link_resp.json()["software"]["name"] == "mastodon")
+    except (KeyError, IndexError):  # pragma: no cover
+        return False
+
+
 def parse_mastodon_post_url(url: str) -> tuple[str, str, str]:
     """
     Parse a Mastodon post URL into its component parts:

tests/conftest.py (0) → tests/conftest.py (144)

diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..302fe1c
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,5 @@
+"""Shared helpers and test fixtures."""
+
+from nitrate.cassettes import cassette_name, vcr_cassette
+
+__all__ = ["cassette_name", "vcr_cassette"]

tests/fixtures/cassettes/TestIsMastodonHost.test_mastodon_servers[social.jvns.ca].yml (0) → tests/fixtures/cassettes/TestIsMastodonHost.test_mastodon_servers[social.jvns.ca].yml (4230)

diff --git a/tests/fixtures/cassettes/TestIsMastodonHost.test_mastodon_servers[social.jvns.ca].yml b/tests/fixtures/cassettes/TestIsMastodonHost.test_mastodon_servers[social.jvns.ca].yml
new file mode 100644
index 0000000..f240d7c
--- /dev/null
+++ b/tests/fixtures/cassettes/TestIsMastodonHost.test_mastodon_servers[social.jvns.ca].yml
@@ -0,0 +1,128 @@
+interactions:
+- request:
+    body: ''
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Host:
+      - social.jvns.ca
+      User-Agent:
+      - python-httpx/0.28.1
+    method: GET
+    uri: https://social.jvns.ca/.well-known/nodeinfo
+  response:
+    body:
+      string: '{"links":[{"rel":"http://nodeinfo.diaspora.software/ns/schema/2.0","href":"https://social.jvns.ca/nodeinfo/2.0"}]}'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Type:
+      - application/json; charset=utf-8
+      Date:
+      - Thu, 04 Dec 2025 12:14:49 GMT
+      Strict-Transport-Security:
+      - max-age=31536000
+      Transfer-Encoding:
+      - chunked
+      cache-control:
+      - max-age=259200, public
+      content-length:
+      - '114'
+      content-security-policy:
+      - 'base-uri ''none''; default-src ''none''; frame-ancestors ''none''; font-src
+        ''self'' https://social.jvns.ca; img-src ''self'' data: blob: https://social.jvns.ca
+        https://cdn.masto.host; media-src ''self'' data: https://social.jvns.ca https://cdn.masto.host;
+        manifest-src ''self'' https://social.jvns.ca; form-action ''self''; child-src
+        ''self'' blob: https://social.jvns.ca; worker-src ''self'' blob: https://social.jvns.ca;
+        connect-src ''self'' data: blob: https://social.jvns.ca https://cdn.masto.host
+        wss://social.jvns.ca; script-src ''self'' https://social.jvns.ca ''wasm-unsafe-eval'';
+        frame-src ''self'' https:; style-src ''self'' https://social.jvns.ca ''nonce-cYXJpX/juTVw0Sc+MAA7BQ=='''
+      etag:
+      - W/"41981c7ccfa1674c1535b6eea835d7e5"
+      referrer-policy:
+      - same-origin
+      server:
+      - Mastodon
+      vary:
+      - Origin
+      x-content-type-options:
+      - nosniff
+      x-frame-options:
+      - DENY
+      x-request-id:
+      - d4888141-9cb3-4305-b5dd-a8b0842a2d05
+      x-runtime:
+      - '0.003398'
+      x-xss-protection:
+      - '0'
+    status:
+      code: 200
+      message: OK
+- request:
+    body: ''
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Host:
+      - social.jvns.ca
+      User-Agent:
+      - python-httpx/0.28.1
+    method: GET
+    uri: https://social.jvns.ca/nodeinfo/2.0
+  response:
+    body:
+      string: '{"version":"2.0","software":{"name":"mastodon","version":"4.5.2"},"protocols":["activitypub"],"services":{"outbound":[],"inbound":[]},"usage":{"users":{"total":4,"activeMonth":4,"activeHalfyear":4},"localPosts":7409},"openRegistrations":false,"metadata":{"nodeName":"Mastodon","nodeDescription":""}}'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Type:
+      - application/json; charset=utf-8
+      Date:
+      - Thu, 04 Dec 2025 12:14:49 GMT
+      Strict-Transport-Security:
+      - max-age=31536000
+      Transfer-Encoding:
+      - chunked
+      cache-control:
+      - max-age=1800, public
+      content-length:
+      - '299'
+      content-security-policy:
+      - 'base-uri ''none''; default-src ''none''; frame-ancestors ''none''; font-src
+        ''self'' https://social.jvns.ca; img-src ''self'' data: blob: https://social.jvns.ca
+        https://cdn.masto.host; media-src ''self'' data: https://social.jvns.ca https://cdn.masto.host;
+        manifest-src ''self'' https://social.jvns.ca; form-action ''self''; child-src
+        ''self'' blob: https://social.jvns.ca; worker-src ''self'' blob: https://social.jvns.ca;
+        connect-src ''self'' data: blob: https://social.jvns.ca https://cdn.masto.host
+        wss://social.jvns.ca; script-src ''self'' https://social.jvns.ca ''wasm-unsafe-eval'';
+        frame-src ''self'' https:; style-src ''self'' https://social.jvns.ca ''nonce-KudwCpfFUyr8bIzc9hLCuA=='''
+      etag:
+      - W/"def238b77fc5db88a115321ee60e49e7"
+      referrer-policy:
+      - same-origin
+      server:
+      - Mastodon
+      vary:
+      - Accept, Origin
+      x-content-type-options:
+      - nosniff
+      x-frame-options:
+      - DENY
+      x-request-id:
+      - 3ef92cee-53b7-41e1-b1f1-5b9b094c5616
+      x-runtime:
+      - '0.008559'
+      x-xss-protection:
+      - '0'
+    status:
+      code: 200
+      message: OK
+version: 1

tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[alexwlchan.net].yml (0) → tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[alexwlchan.net].yml (1406)

diff --git a/tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[alexwlchan.net].yml b/tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[alexwlchan.net].yml
new file mode 100644
index 0000000..6b8e4c1
--- /dev/null
+++ b/tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[alexwlchan.net].yml
@@ -0,0 +1,51 @@
+interactions:
+- request:
+    body: ''
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Host:
+      - alexwlchan.net
+      User-Agent:
+      - python-httpx/0.28.1
+    method: GET
+    uri: https://alexwlchan.net/.well-known/nodeinfo
+  response:
+    body:
+      string: ''
+    headers:
+      Alt-Svc:
+      - h3=":443"; ma=2592000
+      Content-Length:
+      - '0'
+      Content-Security-Policy:
+      - 'default-src ''self'' ''unsafe-inline'' https://youtube-nocookie.com https://www.youtube-nocookie.com;
+        script-src ''self'' ''unsafe-inline''; connect-src https://analytics.alexwlchan.net;
+        img-src ''self'' ''unsafe-inline'' data:'
+      Date:
+      - Thu, 04 Dec 2025 12:15:34 GMT
+      Location:
+      - https://social.alexwlchan.net/.well-known/nodeinfo
+      Permissions-Policy:
+      - geolocation=(), midi=(), notifications=(), push=(), sync-xhr=(), microphone=(),
+        camera=(), magnetometer=(), gyroscope=(), vibrate=(), payment=()
+      Referrer-Policy:
+      - no-referrer-when-downgrade
+      Server:
+      - Caddy
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - ALLOWALL
+      X-Xss-Protection:
+      - 1; mode=block
+    status:
+      code: 301
+      message: Moved Permanently
+version: 1

tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[example.com].yml (0) → tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[example.com].yml (1538)

diff --git a/tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[example.com].yml b/tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[example.com].yml
new file mode 100644
index 0000000..4a0f6f1
--- /dev/null
+++ b/tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[example.com].yml
@@ -0,0 +1,55 @@
+interactions:
+- request:
+    body: ''
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Host:
+      - example.com
+      User-Agent:
+      - python-httpx/0.28.1
+    method: GET
+    uri: https://example.com/.well-known/nodeinfo
+  response:
+    body:
+      string: '<!doctype html><html lang="en"><head><title>Example Domain</title><meta
+        name="viewport" content="width=device-width, initial-scale=1"><style>body{background:#eee;width:60vw;margin:15vh
+        auto;font-family:system-ui,sans-serif}h1{font-size:1.5em}div{opacity:0.8}a:link,a:visited{color:#348}</style><body><div><h1>Example
+        Domain</h1><p>This domain is for use in documentation examples without needing
+        permission. Avoid use in operations.<p><a href="https://iana.org/domains/example">Learn
+        more</a></div></body></html>
+
+        '
+    headers:
+      Accept-Ranges:
+      - bytes
+      Alt-Svc:
+      - h3=":443"; ma=93600
+      Cache-Control:
+      - max-age=0, no-cache, no-store
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '513'
+      Content-Type:
+      - text/html
+      Date:
+      - Thu, 04 Dec 2025 12:15:34 GMT
+      ETag:
+      - '"bc2473a18e003bdb249eba5ce893033f:1760028122.592274"'
+      Expires:
+      - Thu, 04 Dec 2025 12:15:34 GMT
+      Last-Modified:
+      - Thu, 09 Oct 2025 16:42:02 GMT
+      Pragma:
+      - no-cache
+      Server:
+      - AkamaiNetStorage
+    status:
+      code: 404
+      message: Not Found
+version: 1

tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[peertube.tv].yml (0) → tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[peertube.tv].yml (7608)

diff --git a/tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[peertube.tv].yml b/tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[peertube.tv].yml
new file mode 100644
index 0000000..75d1597
--- /dev/null
+++ b/tests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[peertube.tv].yml
@@ -0,0 +1,107 @@
+interactions:
+- request:
+    body: ''
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Host:
+      - peertube.tv
+      User-Agent:
+      - python-httpx/0.28.1
+    method: GET
+    uri: https://peertube.tv/.well-known/nodeinfo
+  response:
+    body:
+      string: '{"links":[{"rel":"http://nodeinfo.diaspora.software/ns/schema/2.0","href":"https://peertube.tv/nodeinfo/2.0.json"}]}'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '116'
+      Content-Type:
+      - application/json; charset=utf-8
+      Date:
+      - Thu, 04 Dec 2025 12:17:46 GMT
+      Server:
+      - nginx/1.18.0 (Ubuntu)
+      access-control-allow-origin:
+      - '*'
+      cache-control:
+      - max-age=548
+      etag:
+      - W/"74-uYd/TxZEF87Urak29pxyd08PwVE"
+      tk:
+      - N
+      x-frame-options:
+      - DENY
+      x-powered-by:
+      - PeerTube
+    status:
+      code: 200
+      message: OK
+- request:
+    body: ''
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Host:
+      - peertube.tv
+      User-Agent:
+      - python-httpx/0.28.1
+    method: GET
+    uri: https://peertube.tv/nodeinfo/2.0.json
+  response:
+    body:
+      string: '{"version":"2.0","software":{"name":"peertube","version":"5.2.0"},"protocols":["activitypub"],"services":{"inbound":[],"outbound":["atom1.0","rss2.0"]},"openRegistrations":false,"usage":{"users":{"total":609,"activeMonth":8,"activeHalfyear":35},"localPosts":18598,"localComments":93},"metadata":{"taxonomy":{"postsName":"Videos"},"nodeName":"PeerTube.TV","nodeDescription":"Videos
+        sharing & live streaming on free open source software PeerTube! No ads, no
+        tracking, no spam.","nodeConfig":{"search":{"remoteUri":{"users":true,"anonymous":false}},"plugin":{"registered":[{"npmName":"peertube-plugin-upload-instructions","name":"upload-instructions","version":"0.1.1","description":"Show
+        an instructions modal right before uploading","clientScripts":{"dist/common-client-plugin.js":{"script":"dist/common-client-plugin.js","scopes":["common"]}}},{"npmName":"peertube-plugin-custom-links","name":"custom-links","version":"0.0.10","description":"PeerTube
+        plugin that allows you to add custom links on the bottom of the menu","clientScripts":{"dist/common-client-plugin.js":{"script":"dist/common-client-plugin.js","scopes":["common"]}}},{"npmName":"peertube-plugin-glavliiit","name":"glavliiit","version":"0.0.10","description":"Enhanced
+        moderation tool for PeerTube","clientScripts":{}},{"npmName":"peertube-plugin-categories","name":"categories","version":"1.2.7","description":"Manage
+        video categories.","clientScripts":{"src/client/admin-plugin-settings.js":{"script":"src/client/admin-plugin-settings.js","scopes":["admin-plugin"]}}},{"npmName":"peertube-plugin-creative-commons","name":"creative-commons","version":"1.2.0","description":"Standardized
+        display of Creative Commons licenses. Uses short identifiers like CC BY-SA
+        4.0 instead of descriptive text.","clientScripts":{"client/video-watch-client-plugin.js":{"script":"client/video-watch-client-plugin.js","scopes":["video-watch"]}}},{"npmName":"peertube-plugin-social-sharing-rus","name":"social-sharing-rus","version":"0.11.0","description":"Share
+        a video or playlist URL on social media (Mastodon, WordPress, reddit, Twitter,
+        etc.)","clientScripts":{"dist/common-client-plugin.js":{"script":"dist/common-client-plugin.js","scopes":["common"]}}},{"npmName":"peertube-plugin-menu-items","name":"menu-items","version":"0.0.4","description":"PeerTube
+        plugin menu-items","clientScripts":{"dist/common-client-plugin.js":{"script":"dist/common-client-plugin.js","scopes":["common"]}}},{"npmName":"peertube-plugin-chapters","name":"chapters","version":"1.1.3","description":"PeerTube
+        chapter plugin","clientScripts":{"dist/client/video-watch-client-plugin.js":{"script":"dist/client/video-watch-client-plugin.js","scopes":["video-watch","embed"]},"dist/client/video-edit-client-plugin.js":{"script":"dist/client/video-edit-client-plugin.js","scopes":["video-edit"]}}},{"npmName":"peertube-plugin-simplelogo","name":"simplelogo","version":"0.0.5","description":"Plugin
+        that let you change logo and favicon on your PeerTube instance.","clientScripts":{"client/common-client-plugin.js":{"script":"client/common-client-plugin.js","scopes":["common"]}}},{"npmName":"peertube-plugin-video-annotation","name":"video-annotation","version":"0.0.7","description":"PeerTube
+        plugin video annotation","clientScripts":{"dist/embed-client-plugin.js":{"script":"dist/embed-client-plugin.js","scopes":["embed"]},"dist/video-edit-client-plugin.js":{"script":"dist/video-edit-client-plugin.js","scopes":["video-edit"]},"dist/video-watch-client-plugin.js":{"script":"dist/video-watch-client-plugin.js","scopes":["video-watch"]}}},{"npmName":"peertube-plugin-livechat","name":"livechat","version":"7.2.1","description":"PeerTube
+        plugin livechat: offers a way to embed a chat system into Peertube.","clientScripts":{"dist/client/videowatch-client-plugin.js":{"script":"dist/client/videowatch-client-plugin.js","scopes":["video-watch"]},"dist/client/common-client-plugin.js":{"script":"dist/client/common-client-plugin.js","scopes":["common"]},"dist/client/admin-plugin-client-plugin.js":{"script":"dist/client/admin-plugin-client-plugin.js","scopes":["admin-plugin"]}}}]},"theme":{"registered":[{"npmName":"peertube-theme-dark-evolution","name":"dark-evolution","version":"1.0.4","description":"Evolution
+        of the official PeerTube dark theme","css":["assets/style.css"],"clientScripts":{}},{"npmName":"peertube-theme-dark","name":"dark","version":"2.5.0","description":"PeerTube
+        dark theme","css":["assets/style.css"],"clientScripts":{}}],"default":"dark-evolution"},"email":{"enabled":true},"contactForm":{"enabled":true},"transcoding":{"hls":{"enabled":true},"webtorrent":{"enabled":true},"enabledResolutions":[144,240,360,480,720,1080]},"live":{"enabled":true,"transcoding":{"enabled":true,"enabledResolutions":[144,480,720,1080]}},"import":{"videos":{"http":{"enabled":true},"torrent":{"enabled":false}}},"autoBlacklist":{"videos":{"ofUsers":{"enabled":false}}},"avatar":{"file":{"size":{"max":4194304},"extensions":[".png",".jpeg",".jpg",".gif",".webp"]}},"video":{"image":{"extensions":[".png",".jpg",".jpeg",".webp"],"size":{"max":4194304}},"file":{"extensions":[".webm",".ogv",".ogg",".mp4",".mkv",".mov",".qt",".mqv",".m4v",".flv",".f4v",".wmv",".avi",".3gp",".3gpp",".3g2",".3gpp2",".nut",".mts",".m2ts",".mpv",".m2v",".m1v",".mpg",".mpe",".mpeg",".vob",".mxf",".mp3",".wma",".wav",".flac",".aac",".m4a",".ac3"]}},"videoCaption":{"file":{"size":{"max":20971520},"extensions":[".vtt",".srt"]}},"user":{"videoQuota":53687091200,"videoQuotaDaily":5368709120},"trending":{"videos":{"intervalDays":7}},"tracker":{"enabled":true}}}}'
+    headers:
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '5567'
+      Content-Type:
+      - application/json; charset=utf-8; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"
+      Date:
+      - Thu, 04 Dec 2025 12:17:47 GMT
+      ETag:
+      - W/"15bf-UHcLfIV97HliD7E2eKuWJsf3iEQ"
+      Server:
+      - nginx/1.18.0 (Ubuntu)
+      Tk:
+      - N
+      X-Frame-Options:
+      - DENY
+      cache-control:
+      - max-age=600
+      x-powered-by:
+      - PeerTube
+    status:
+      code: 200
+      message: OK
+version: 1

tests/stubs/vcr.cassette.pyi (0) → tests/stubs/vcr.cassette.pyi (20)

diff --git a/tests/stubs/vcr.cassette.pyi b/tests/stubs/vcr.cassette.pyi
new file mode 100644
index 0000000..5c8ea15
--- /dev/null
+++ b/tests/stubs/vcr.cassette.pyi
@@ -0,0 +1 @@
+class Cassette: ...

tests/test_urls.py (2653) → tests/test_urls.py (3685)

diff --git a/tests/test_urls.py b/tests/test_urls.py
index b4a0eb4..5666796 100644
--- a/tests/test_urls.py
+++ b/tests/test_urls.py
@@ -1,9 +1,11 @@
 """Tests for `chives.urls`."""
 
 import pytest
+from vcr.cassette import Cassette
 
 from chives.urls import (
     clean_youtube_url,
+    is_mastodon_host,
     parse_mastodon_post_url,
     parse_tumblr_post_url,
 )
@@ -92,3 +94,37 @@ def test_parse_tumblr_post_url(url: str, blog_identifier: str, post_id: str) -> 
     Tumblr URLs are parsed correctly.
     """
     assert parse_tumblr_post_url(url) == (blog_identifier, post_id)
+
+
+class TestIsMastodonHost:
+    """
+    Tests for `is_mastodon_host`.
+    """
+
+    @pytest.mark.parametrize(
+        "host", ["mastodon.social", "hachyderm.io", "social.jvns.ca"]
+    )
+    def test_mastodon_servers(self, host: str, vcr_cassette: Cassette) -> None:
+        """
+        It correctly identifies real Mastodon servers.
+        """
+        assert is_mastodon_host(host)
+
+    @pytest.mark.parametrize(
+        "host",
+        [
+            # These are regular Internet websites which don't expose
+            # the /.well-known/nodeinfo endpoint
+            "example.com",
+            "alexwlchan.net",
+            #
+            # PeerTube exposes /.well-known/nodeinfo, but it's running
+            # different software.
+            "peertube.tv",
+        ],
+    )
+    def test_non_mastodon_servers(self, host: str, vcr_cassette: Cassette) -> None:
+        """
+        Other websites are not Mastodon servers.
+        """
+        assert not is_mastodon_host(host)