diff --git a/lib/docs/filters/pytest/clean_html.rb b/lib/docs/filters/pytest/clean_html.rb
new file mode 100644
index 0000000000..70411e9cb3
--- /dev/null
+++ b/lib/docs/filters/pytest/clean_html.rb
@@ -0,0 +1,33 @@
+module Docs
+ class Pytest
+ class CleanHtmlFilter < Filter
+ def call
+ # Removed here rather than by sphinx/clean_html because the entries
+ # filter reads the headings' text content
+ css('.headerlink').remove
+
+ # "[source]" links pointing to the highlighted sources, which aren't scraped
+ css('.viewcode-link').each do |node|
+ node.parent.remove
+ end
+
+ # Tab sets (e.g. the configuration file formats) are made of hidden radio
+ # inputs and labels, which don't work without the theme's stylesheet. The
+ # tabs are turned into consecutive sections instead.
+ css('.tab-input').remove
+
+ css('.tab-label').each do |node|
+ node.name = 'h4'
+ node.remove_attribute('class')
+ node.remove_attribute('for')
+ end
+
+ css('.tab-content', '.tab-set').each do |node|
+ node.before(node.children).remove
+ end
+
+ doc
+ end
+ end
+ end
+end
diff --git a/lib/docs/filters/pytest/entries.rb b/lib/docs/filters/pytest/entries.rb
new file mode 100644
index 0000000000..d9637c7f63
--- /dev/null
+++ b/lib/docs/filters/pytest/entries.rb
@@ -0,0 +1,71 @@
+module Docs
+ class Pytest
+ class EntriesFilter < Docs::EntriesFilter
+ # The documentation follows the Diátaxis framework, which its URLs mirror
+ TYPE_BY_DIRECTORY = {
+ 'how-to' => 'How-to guides',
+ 'reference' => 'Reference',
+ 'explanation' => 'Explanation',
+ 'example' => 'Examples'
+ }
+
+ # The only page documenting objects; its second-level sections (Functions,
+ # Marks, Fixtures, Hooks, ...) are used as the types of those objects
+ API_REFERENCE_SLUG = 'reference/reference'
+
+ # Objects are documented under the module implementing them, which is part
+ # of neither pytest's public API nor of the names shown in the signatures
+ PRIVATE_MODULE = /\Apytest\.(?:capture|doctest|fixtures|hookspec|junitxml|logging|monkeypatch|nodes|python|recwarn|tmpdir)\./
+
+ CALLABLE = /\b(?:function|method|classmethod|staticmethod)\b/
+
+ def get_name
+ at_css('h1').content.strip
+ end
+
+ def get_type
+ TYPE_BY_DIRECTORY[slug.split('/').first] || 'Get Started'
+ end
+
+ def additional_entries
+ return [] unless slug == API_REFERENCE_SLUG
+
+ entries = []
+
+ css('> section').each do |section|
+ next unless heading = section.at_css('> h2')
+ type = heading.content.strip
+
+ section.css('dl.py > dt[id], dl.std > dt[id]').each do |node|
+ entries << [object_name(node), node['id'], type]
+ end
+
+ # Constants and a few other objects (e.g. custom marks) are documented
+ # as plain sections rather than as Sphinx objects
+ section.css('> section').each do |subsection|
+ next if subsection.at_css('dl.py > dt[id], dl.std > dt[id]')
+ next unless subheading = subsection.at_css('> h3')
+ entries << [subheading.content.strip, subsection['id'], type]
+ end
+ end
+
+ entries
+ end
+
+ private
+
+ def object_name(node)
+ classes = node.parent['class']
+
+ # Configuration options, command-line flags, environment variables and
+ # global variables have prefixed ids (e.g. "confval-addopts"), the
+ # signature is the only place where they appear as one writes them
+ return node.at_css('.descname').content.strip if classes.include?('std')
+
+ name = node['id'].sub(PRIVATE_MODULE, '')
+ name << '()' if classes =~ CALLABLE
+ name
+ end
+ end
+ end
+end
diff --git a/lib/docs/scrapers/pytest.rb b/lib/docs/scrapers/pytest.rb
new file mode 100644
index 0000000000..3ca8cb2c46
--- /dev/null
+++ b/lib/docs/scrapers/pytest.rb
@@ -0,0 +1,43 @@
+module Docs
+ class Pytest < UrlScraper
+ self.name = 'pytest'
+ self.type = 'sphinx'
+ self.release = '9.1.1'
+ self.base_url = 'https://docs.pytest.org/en/stable/'
+ self.root_path = 'index.html'
+ self.links = {
+ home: 'https://pytest.org/',
+ code: 'https://github.com/pytest-dev/pytest'
+ }
+
+ # The navigation lives in the Furo sidebar, i.e. outside of the scraped
+ # container, and the toctrees on the home page are hidden ones. The section
+ # indexes, whose toctrees are part of the content, are used as entry points
+ # instead.
+ self.initial_paths = %w(
+ getting-started.html
+ how-to/index.html
+ reference/index.html
+ explanation/index.html
+ example/index.html)
+
+ html_filters.push 'pytest/clean_html', 'pytest/entries', 'sphinx/clean_html'
+
+ options[:container] = 'article[role="main"] > section'
+
+ # Restrict the scraper to the documentation itself, leaving out the project's
+ # meta pages (changelog, contributing, sponsors, announcements, ...) and the
+ # highlighted sources under _modules/.
+ options[:only] = %w(index.html getting-started.html)
+ options[:only_patterns] = [/\Ahow-to\//, /\Areference\//, /\Aexplanation\//, /\Aexample\//]
+
+ options[:attribution] = <<-HTML
+ © 2015–2026 Holger Krekel and pytest-dev team
+ Licensed under the MIT License.
+ HTML
+
+ def get_latest_version(opts)
+ get_latest_github_release('pytest-dev', 'pytest', opts)
+ end
+ end
+end
diff --git a/public/icons/docs/pytest/16.png b/public/icons/docs/pytest/16.png
new file mode 100644
index 0000000000..71fb26b884
Binary files /dev/null and b/public/icons/docs/pytest/16.png differ
diff --git a/public/icons/docs/pytest/16@2x.png b/public/icons/docs/pytest/16@2x.png
new file mode 100644
index 0000000000..461da14ca7
Binary files /dev/null and b/public/icons/docs/pytest/16@2x.png differ
diff --git a/public/icons/docs/pytest/SOURCE b/public/icons/docs/pytest/SOURCE
new file mode 100644
index 0000000000..4db6b8149f
--- /dev/null
+++ b/public/icons/docs/pytest/SOURCE
@@ -0,0 +1 @@
+https://github.com/pytest-dev/pytest/blob/main/doc/en/_static/pytest1.png