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.gitattributesCHANGELOG.mddev_requirements.indev_requirements.txtpyproject.tomlsrc/chives/__init__.pysrc/chives/urls.pytests/conftest.pytests/fixtures/cassettes/TestIsMastodonHost.test_mastodon_servers[social.jvns.ca].ymltests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[alexwlchan.net].ymltests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[example.com].ymltests/fixtures/cassettes/TestIsMastodonHost.test_non_mastodon_servers[peertube.tv].ymltests/stubs/vcr.cassette.pyitests/test_urls.py
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)