Repository: crawler-commons/crawler-commons (java)

Issue:
[Sitemaps] Complete sitemap extension attributes
The sitemap extensions are missing some attributes. These are declared in the corresponding schema but are not documented in the description or the shown in the example. Also, these attributes are infrequently used.

- [news sitemap](https://www.google.com/schemas/sitemap-news/0.9/sitemap-news.xsd):
  - `access`
     ```xml
      <xsd:element name="access" minOccurs="0">
        <xsd:annotation>
          <xsd:documentation>
            Accessibility of the article.  Required if access is not open,
            otherwise this tag should be omitted.
          </xsd:documentation>
        </xsd:annotation>
        <xsd:simpleType>
          <xsd:restriction base="xsd:string">
            <xsd:enumeration value="Subscription"/>
            <xsd:enumeration value="Registration"/>
          </xsd:restriction>
        </xsd:simpleType>
      </xsd:element>
     ```
- [video sitemap](https://www.google.com/schemas/sitemap-video/1.1/sitemap-video.xsd):
  - `content_segment_loc`
  - `episode_number`
  - `episode_title`
  - `premier_date`
  - `season_number`
  - `show_title`
  - `tvshow`
  - `video_type`
- [PageMap](https://www.google.com/schemas/sitemap-pagemap/1.0/sitemap-pagemap.xsd)
  - `Template`

Nevertheless, we might add support for them to the sitemap extension parser.

Pull request description:
[Sitemaps] Complete sitemap extension attributes
Fixes #513.

Note: this draft PR is under construction and the unit test is failing because the video attribute does not validate (cf. #458).

Patch:
diff --git a/src/main/java/crawlercommons/sitemaps/extension/NewsAttributes.java b/src/main/java/crawlercommons/sitemaps/extension/NewsAttributes.java
index 176e15f..793053d 100644
--- a/src/main/java/crawlercommons/sitemaps/extension/NewsAttributes.java
+++ b/src/main/java/crawlercommons/sitemaps/extension/NewsAttributes.java
@@ -24,18 +24,23 @@ import java.util.Objects;
 
 /**
  * Data model for Google's extension to the sitemap protocol regarding news
- * indexing, as per <a
- * href="http://www.google.com/schemas/sitemap-news/0.9">http
+ * indexing, as per <a href=
+ * "http://www.google.com/schemas/sitemap-news/0.9">http
  * ://www.google.com/schemas/sitemap-news/0.9</a>.
  */
 @SuppressWarnings("serial")
 public class NewsAttributes extends ExtensionMetadata {
 
+    /** Name of the news publication in which the article appears. */
     public static final String NAME = "name";
+    /** Language of the news publication in which the article appears. */
     public static final String LANGUAGE = "language";
     public static final String GENRES = "genres";
     public static final String PUBLICATION_DATE = "publication_date";
+    /** Title of the news article. */
     public static final String TITLE = "title";
+    /** Accessibility of the news article. */
+    public static final String ACCESS = "access";
     public static final String KEYWORDS = "keywords";
     public static final String STOCK_TICKERS = "stock_tickers";
 
@@ -43,6 +48,10 @@ public class NewsAttributes extends ExtensionMetadata {
         Blog, OpEd, Opinion, PressRelease, Satire, UserGenerated
     }
 
+    public static enum AccessOption {
+        Subscription, Registration
+    }
+
     /**
      * News publication name found under news/publication/name (required)
      */
@@ -81,6 +90,9 @@ public class NewsAttributes extends ExtensionMetadata {
      */
     private String[] stockTickers;
 
+    /** Accessibility of the news article. */
+    private AccessOption access;
+
     public NewsAttributes() {
     }
 
@@ -91,6 +103,7 @@ public class NewsAttributes extends ExtensionMetadata {
         this.title = title;
     }
 
+    /** @return name of the news publication */
     public String getName() {
         return name;
     }
@@ -99,6 +112,7 @@ public class NewsAttributes extends ExtensionMetadata {
         this.name = name;
     }
 
+    /** @return language of the news publication */
     public String getLanguage() {
         return language;
     }
@@ -154,6 +168,14 @@ public class NewsAttributes extends ExtensionMetadata {
         this.stockTickers = stockTickers;
     }
 
+    public AccessOption getAccess() {
+        return access;
+    }
+
+    public void setAccess(String access) {
+        this.access = AccessOption.valueOf(access);
+    }
+
     @Override
     public boolean equals(Object other) {
         if (other == null || !(other instanceof NewsAttributes)) {
@@ -164,6 +186,7 @@ public class NewsAttributes extends ExtensionMetadata {
                         && Objects.equals(language, that.language) //
                         && Objects.equals(title, that.title) //
                         && Objects.equals(publicationDate, that.publicationDate) //
+                        && Objects.equals(access, that.access) //
                         && Objects.deepEquals(keywords, that.keywords) //
                         && Objects.deepEquals(genres, that.genres) //
                         && Objects.deepEquals(stockTickers, that.stockTickers);
@@ -195,7 +218,10 @@ public class NewsAttributes extends ExtensionMetadata {
             }
         }
         if (stockTickers != null) {
-            sb.append(", keywords: ").append(String.join(", ", stockTickers));
+            sb.append(", stock_tickers: ").append(String.join(", ", stockTickers));
+        }
+        if (access != null) {
+            sb.append(", access: ").append(access);
         }
         return sb.toString();
     }
diff --git a/src/main/java/crawlercommons/sitemaps/extension/VideoAttributes.java b/src/main/java/crawlercommons/sitemaps/extension/VideoAttributes.java
index b75a78f..b10d0ad 100644
--- a/src/main/java/crawlercommons/sitemaps/extension/VideoAttributes.java
+++ b/src/main/java/crawlercommons/sitemaps/extension/VideoAttributes.java
@@ -196,6 +196,12 @@ public class VideoAttributes extends ExtensionMetadata {
      */
     private Boolean isLive;
 
+    /** If video is published as a series of raw videos */
+    private ContentSegment[] contentSegments;
+
+    /** Information about a single TV video */
+    private TVShow tvShow;
+
     public URL getThumbnailLoc() {
         return thumbnailLoc;
     }
@@ -519,6 +525,171 @@ public class VideoAttributes extends ExtensionMetadata {
 
     }
 
+    /**
+     * <blockquote>
+     * <p>
+     * Use &lt;video:content_segment_loc&gt; only in conjunction with
+     * &lt;video:player_loc&gt;.
+     * </p>
+     * 
+     * <p>
+     * If you publish your video as a series of raw videos (for example, if you
+     * submit a full movie as a continuous series of shorter clips), you can use
+     * the &lt;video:content_segment_loc&gt; to supply us with a series of URLs,
+     * in the order in which they should be concatenated to recreate the video
+     * in its entirety. Each URL should point to a .mpg, .mpeg, .mp4, .m4v,
+     * .mov, .wmv, .asf, .avi, .ra, .ram, .rm, .flv, or other video file format.
+     * It should not point to any Flash content.
+     * </p>
+     * </blockquote>
+     * 
+     * (Source: <a href=
+     * "https://www.google.com/schemas/sitemap-video/1.1/sitemap-video.xsd">sitemap-video.xsd</a>)
+     */
+    public static final class ContentSegment implements Serializable {
+        private Integer duration;
+        private URL loc;
+
+        public ContentSegment(Integer duration, URL loc) {
+            this.duration = duration;
+            this.loc = loc;
+        }
+
+        public Integer getDuration() {
+            return duration;
+        }
+
+        public URL getLocation() {
+            return loc;
+        }
+
+        @Override
+        public String toString() {
+            return "segment: " + loc.toString() + " (" + duration + ")";
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            if (other == null) {
+                return false;
+            }
+            if (!(other instanceof ContentSegment)) {
+                return false;
+            }
+            return urlEquals(loc, ((ContentSegment) other).loc) && Objects.equals(duration, ((ContentSegment) other).duration);
+        }
+    }
+
+    public ContentSegment[] getContentSegmentLocs() {
+        return contentSegments;
+    }
+
+    public void setContentSegmentLocs(ContentSegment[] segments) {
+        contentSegments = segments;
+    }
+
+    public void addContentSegment(Integer duration, URL loc) {
+        ContentSegment segment = new ContentSegment(duration, loc);
+        if (contentSegments == null) {
+            contentSegments = new ContentSegment[1];
+            contentSegments[0] = segment;
+        } else {
+            ContentSegment[] arr = Arrays.copyOf(contentSegments, contentSegments.length + 1);
+            arr[contentSegments.length] = segment;
+            contentSegments = arr;
+        }
+    }
+
+    public static final class TVShow implements Serializable {
+        public enum VideoType {
+            full, preview, clip, interview, news, other;
+        }
+
+        private String showTitle;
+        private VideoType videoType;
+        private String episodeTitle;
+        private Integer seasonNumber;
+        private Integer episodeNumber;
+        private ZonedDateTime premierDate;
+
+        public String getShowTitle() {
+            return showTitle;
+        }
+
+        public void setShowTitle(String title) {
+            showTitle = title;
+        }
+
+        public VideoType getVideoType() {
+            return videoType;
+        }
+
+        public void setVideoType(VideoType videoType) {
+            this.videoType = videoType;
+        }
+
+        public void setVideoType(String videoType) {
+            this.videoType = VideoType.valueOf(videoType);
+        }
+
+        public String getEpisodeTitle() {
+            return episodeTitle;
+        }
+
+        public void setEpisodeTitle(String title) {
+            episodeTitle = title;
+        }
+
+        public Integer getSeasonNumber() {
+            return seasonNumber;
+        }
+
+        public void setSeasonNumber(Integer num) {
+            this.seasonNumber = num;
+        }
+
+        public Integer getEpisodeNumber() {
+            return episodeNumber;
+        }
+
+        public void setEpisodeNumber(Integer num) {
+            this.episodeNumber = num;
+        }
+
+        public ZonedDateTime getPremierDate() {
+            return premierDate;
+        }
+
+        public void setPremierDate(ZonedDateTime premierDate) {
+            this.premierDate = premierDate;
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            if (other == null) {
+                return false;
+            }
+            if (!(other instanceof TVShow)) {
+                return false;
+            }
+            TVShow o = (TVShow) other;
+            return showTitle.equals(o.showTitle) //
+                            && videoType.equals(o.videoType) //
+                            && episodeTitle.equals(o.episodeTitle) //
+                            && seasonNumber.equals(o.seasonNumber) //
+                            && episodeNumber.equals(o.episodeNumber) //
+                            && premierDate.equals(o.premierDate);
+        }
+    }
+
+    public TVShow getTVShow() {
+        return tvShow;
+    }
+
+    public void setTVShow(TVShow show) {
+        tvShow = show;
+    }
+
     public VideoAttributes() {
     }
 
@@ -573,7 +744,9 @@ public class VideoAttributes extends ExtensionMetadata {
                         && Objects.equals(uploaderInfo, that.uploaderInfo) //
                         && Objects.deepEquals(allowedPlatforms, that.allowedPlatforms) //
                         && Objects.deepEquals(restrictedPlatforms, that.restrictedPlatforms) //
-                        && Objects.equals(isLive, that.isLive);
+                        && Objects.equals(isLive, that.isLive) //
+                        && Objects.deepEquals(contentSegments, that.contentSegments) //
+                        && Objects.equals(tvShow, that.tvShow);
     }
 
     @Override
diff --git a/src/main/java/crawlercommons/sitemaps/sax/extension/NewsHandler.java b/src/main/java/crawlercommons/sitemaps/sax/extension/NewsHandler.java
index 8730178..74b90b0 100644
--- a/src/main/java/crawlercommons/sitemaps/sax/extension/NewsHandler.java
+++ b/src/main/java/crawlercommons/sitemaps/sax/extension/NewsHandler.java
@@ -76,11 +76,13 @@ public class NewsHandler extends ExtensionHandler {
         }
         if (value.isEmpty()) {
             // skip value but reset StringBuilder
-        } else if ("name".equals(localName)) {
-            currAttr.setName(value);
         } else if ("title".equals(localName)) {
             currAttr.setTitle(value);
+        } else if ("name".equals(localName)) {
+            // inside element "publication"
+            currAttr.setName(value);
         } else if ("language".equals(localName)) {
+            // inside element "publication"
             currAttr.setLanguage(value);
         } else if ("publication_date".equals(localName)) {
             currAttr.setPublicationDate(getDateValue(value));
@@ -103,6 +105,8 @@ public class NewsHandler extends ExtensionHandler {
                 stockTickers = Arrays.copyOfRange(stockTickers, 0, 5);
             }
             currAttr.setStockTickers(stockTickers);
+        } else if ("access".equals(localName)) {
+            currAttr.setAccess(value);
         }
         // reset StringBuilder
         currVal = new StringBuilder();
diff --git a/src/main/java/crawlercommons/sitemaps/sax/extension/VideoHandler.java b/src/main/java/crawlercommons/sitemaps/sax/extension/VideoHandler.java
index 7f0bcfa..9b02739 100644
--- a/src/main/java/crawlercommons/sitemaps/sax/extension/VideoHandler.java
+++ b/src/main/java/crawlercommons/sitemaps/sax/extension/VideoHandler.java
@@ -36,6 +36,8 @@ public class VideoHandler extends ExtensionHandler {
     private VideoAttributes currAttr;
     private StringBuilder currVal;
     private String relationAttr;
+    private Integer contentSegmentLocDuration;
+    private VideoAttributes.TVShow tvShow;
     private Map<String, String> priceAttr;
     private static String[] PRICE_ATTRIBUTES = { "currency", "type", "resolution" };
 
@@ -54,6 +56,15 @@ public class VideoHandler extends ExtensionHandler {
         currVal = new StringBuilder();
     }
 
+    private Integer validateDuration(String value) {
+        Integer duration = getIntegerValue(value);
+        if (duration != null && (duration < 0 || duration > 28800)) {
+            LOG.debug("Invalid value for specified duration: {}", duration);
+            return null;
+        }
+        return duration;
+    }
+
     @Override
     public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
         if ("video".equals(localName)) {
@@ -82,6 +93,10 @@ public class VideoHandler extends ExtensionHandler {
                     priceAttr.put(a, v);
                 }
             }
+        } else if ("content_segment_loc".equals(localName)) {
+            contentSegmentLocDuration = validateDuration(attributes.getValue("duration"));
+        } else if ("tvshow".equals(localName)) {
+            tvShow = new VideoAttributes.TVShow();
         }
     }
 
@@ -128,6 +143,9 @@ public class VideoHandler extends ExtensionHandler {
             VideoPrice price = new VideoPrice(currency, fvalue, type, resolution);
             currAttr.addPrice(price);
             priceAttr = null;
+        } else if ("tvshow".equals(localName)) {
+            currAttr.setTVShow(tvShow);
+            tvShow = null;
         } else if (value.isEmpty()) {
             // skip value but reset StringBuilder
         } else if ("thumbnail_loc".equals(localName)) {
@@ -141,12 +159,10 @@ public class VideoHandler extends ExtensionHandler {
         } else if ("player_loc".equals(localName)) {
             currAttr.setPlayerLoc(getURLValue(value));
         } else if ("duration".equals(localName)) {
-            Integer duration = getIntegerValue(value);
-            if (duration != null && (duration < 0 || duration > 28800)) {
-                LOG.debug("Invalid value for specified duration: {}", duration);
-                duration = null;
-            }
-            currAttr.setDuration(duration);
+            currAttr.setDuration(validateDuration(value));
+        } else if ("content_segment_loc".equals(localName)) {
+            currAttr.addContentSegment(contentSegmentLocDuration, getURLValue(value));
+            contentSegmentLocDuration = null;
         } else if ("expiration_date".equals(localName)) {
             currAttr.setExpirationDate(getDateValue(value));
         } else if ("rating".equals(localName)) {
@@ -189,6 +205,48 @@ public class VideoHandler extends ExtensionHandler {
             relationAttr = null;
         } else if ("live".equals(localName)) {
             currAttr.setLive(getYesNoBooleanValue(value, localName));
+        } else if ("show_title".equals(localName)) {
+            if (tvShow != null) {
+                tvShow.setShowTitle(value);
+            } else {
+                LOG.debug("Element <show_title> outside enclosing element <tvshow>!");
+            }
+        } else if ("video_type".equals(localName)) {
+            if (tvShow != null) {
+                tvShow.setVideoType(value);
+            } else {
+                LOG.debug("Element <video_type> outside enclosing element <tvshow>!");
+            }
+        } else if ("episode_title".equals(localName)) {
+            if (tvShow != null) {
+                tvShow.setEpisodeTitle(value);
+            } else {
+                LOG.debug("Element <episode_title> outside enclosing element <tvshow>!");
+            }
+        } else if ("".equals(localName)) {
+            if (tvShow != null) {
+                tvShow.setVideoType(value);
+            } else {
+                LOG.debug("Element <> outside enclosing element <tvshow>!");
+            }
+        } else if ("season_number".equals(localName)) {
+            if (tvShow != null) {
+                tvShow.setSeasonNumber(getIntegerValue(value));
+            } else {
+                LOG.debug("Element <season_number> outside enclosing element <tvshow>!");
+            }
+        } else if ("episode_number".equals(localName)) {
+            if (tvShow != null) {
+                tvShow.setEpisodeNumber(getIntegerValue(value));
+            } else {
+                LOG.debug("Element <episode_number> outside enclosing element <tvshow>!");
+            }
+        } else if ("premier_date".equals(localName)) {
+            if (tvShow != null) {
+                tvShow.setPremierDate(getDateValue(value));
+            } else {
+                LOG.debug("Element <premier_date> outside enclosing element <tvshow>!");
+            }
         }
         // reset StringBuilder
         currVal = new StringBuilder();


Test patch:
diff --git a/src/test/java/crawlercommons/sitemaps/SiteMapParserExtensionTest.java b/src/test/java/crawlercommons/sitemaps/SiteMapParserExtensionTest.java
index 4014e4b..fc42894 100644
--- a/src/test/java/crawlercommons/sitemaps/SiteMapParserExtensionTest.java
+++ b/src/test/java/crawlercommons/sitemaps/SiteMapParserExtensionTest.java
@@ -26,7 +26,6 @@ import java.io.IOException;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.time.ZonedDateTime;
-import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -113,6 +112,56 @@ public class SiteMapParserExtensionTest {
         assertEquals(expectedVideoAttributes, attr);
     }
 
+    @Test
+    public void testVideosSitemapTVShow() throws UnknownFormatException, IOException {
+        SiteMapParser parser = new SiteMapParser();
+        parser.enableExtension(Extension.VIDEO);
+
+        URL url = new URL("https://example.org/sitemap.xml");
+        AbstractSiteMap asm = parse(parser, "src/test/resources/sitemaps/extension/sitemap-videos-tvshow.xml", url);
+
+        assertEquals(false, asm.isIndex());
+        assertEquals(true, asm instanceof SiteMap);
+        SiteMap sm = (SiteMap) asm;
+        assertEquals(1, sm.getSiteMapUrls().size());
+        Iterator<SiteMapURL> siter = sm.getSiteMapUrls().iterator();
+        ExtensionMetadata[] attrs = siter.next().getAttributesForExtension(Extension.VIDEO);
+        assertEquals(1, attrs.length);
+        VideoAttributes attr = (VideoAttributes) attrs[0];
+        assertNotNull(attr);
+
+        VideoAttributes expectedVideoAttributes = new VideoAttributes( //
+                        new URL("https://example.org/my-tv-show.jpg"), //
+                        "My TV Show! - Thu, Jun 05, 2025", //
+                        "Dummy description", null, null);
+        expectedVideoAttributes.setDuration(2459);
+        ZonedDateTime dt = ZonedDateTime.parse("2025-06-13T09:00:00+00:00");
+        expectedVideoAttributes.setExpirationDate(dt);
+        dt = ZonedDateTime.parse("2025-06-06T09:00:00+00:00");
+        expectedVideoAttributes.setPublicationDate(dt);
+        expectedVideoAttributes.setTags(new String[] { "talkshow", "interview", "example" });
+        expectedVideoAttributes.setCategory("My TV Show!");
+        expectedVideoAttributes.setRequiresSubscription(true);
+        expectedVideoAttributes.setContentLoc(new URL("https://example.org/video/video/23-13.mp4"));
+        expectedVideoAttributes.setPlayerLoc(new URL("https://example.org/video/embed/23-13"));
+        expectedVideoAttributes.addContentSegment(1287, new URL("https://example.org/video/video/23-13-1.mp4"));
+        expectedVideoAttributes.addContentSegment(1172, new URL("https://example.org/video/video/23-13-2.mp4"));
+        VideoAttributes.TVShow tvShow = new VideoAttributes.TVShow();
+        tvShow.setShowTitle("My TV Show!");
+        tvShow.setVideoType("full");
+        tvShow.setEpisodeTitle("Thu, Jun 05, 2025");
+        tvShow.setSeasonNumber(23);
+        tvShow.setEpisodeNumber(13);
+        dt = ZonedDateTime.parse("2025-06-05T21:00Z");
+        tvShow.setPremierDate(dt);
+        expectedVideoAttributes.setTVShow(tvShow);
+
+        assertTrue(expectedVideoAttributes.isValid());
+        assertTrue(attr.isValid());
+        assertEquals(expectedVideoAttributes, attr);
+        assertEquals(expectedVideoAttributes.toString(), attr.toString());
+    }
+
     @Test
     public void testImageSitemap() throws UnknownFormatException, IOException {
         SiteMapParser parser = new SiteMapParser();
@@ -208,12 +257,16 @@ public class SiteMapParserExtensionTest {
         expectedNewsAttributes.setKeywords(new String[] { "business", "merger", "acquisition", "A", "B" });
         expectedNewsAttributes.setGenres(new NewsAttributes.NewsGenre[] { NewsAttributes.NewsGenre.PressRelease, NewsAttributes.NewsGenre.Blog });
         expectedNewsAttributes.setStockTickers(new String[] { "NASDAQ:A", "NASDAQ:B" });
+        expectedNewsAttributes.setAccess("Subscription");
         Iterator<SiteMapURL> it = sm.getSiteMapUrls().iterator();
         SiteMapURL su = it.next();
         assertNotNull(su.getAttributesForExtension(Extension.NEWS));
         assertEquals(1, su.getAttributesForExtension(Extension.NEWS).length);
         NewsAttributes attr = (NewsAttributes) su.getAttributesForExtension(Extension.NEWS)[0];
         assertEquals(expectedNewsAttributes, attr);
+        assertNotNull(su.getAttributesForExtension(Extension.NEWS));
+        assertEquals("Subscription", attr.getAccess().toString());
+        assertEquals(expectedNewsAttributes.toString(), attr.toString());
 
         // second sitemap record with missing / empty attributes
         su = it.next();
@@ -226,6 +279,7 @@ public class SiteMapParserExtensionTest {
         attr = (NewsAttributes) su.getAttributesForExtension(Extension.NEWS)[0];
         assertFalse(attr.isValid());
         assertEquals(expectedNewsAttributes, attr);
+        assertEquals(expectedNewsAttributes.toString(), attr.toString());
     }
 
     @Test
@@ -310,10 +364,8 @@ public class SiteMapParserExtensionTest {
         assertEquals(urlStr, sm.getUrl().toString());
         // System.out.println(sm.toString());
         for (SiteMapURL u : sm.getSiteMapUrls()) {
-            System.out.println(u.toString());
             for (Entry<Extension, ExtensionMetadata[]> x : u.getAttributes().entrySet()) {
                 assertEquals(Extension.PAGEMAPS, x.getKey());
-                System.out.println(x.getValue().getClass());
                 PageMap pageMap = (PageMap) x.getValue()[0];
                 List<PageMapDataObject> dataObjects = pageMap.getPageMapDataObjects();
                 PageMapDataObject dataObject;
@@ -339,8 +391,6 @@ public class SiteMapParserExtensionTest {
                     assertEquals("4.0", dataObject.getAttribute("review"));
                         break;
                 }
-                System.out.println(x.getKey() + ": " + Arrays.toString(x.getValue()));
-                System.out.println(x.getValue().length);
             }
         }
     }
diff --git a/src/test/resources/sitemaps/extension/sitemap-news.xml b/src/test/resources/sitemaps/extension/sitemap-news.xml
index b7c91ed..50fe8e1 100644
--- a/src/test/resources/sitemaps/extension/sitemap-news.xml
+++ b/src/test/resources/sitemaps/extension/sitemap-news.xml
@@ -17,6 +17,7 @@
       <news:title>Companies A, B in Merger Talks</news:title>
       <news:keywords>business, merger, acquisition, A, B</news:keywords>
       <news:stock_tickers>NASDAQ:A, NASDAQ:B</news:stock_tickers>
+      <news:access>Subscription</news:access>
     </news:news>
   </url>
   <url>
diff --git a/src/test/resources/sitemaps/extension/sitemap-videos-tvshow.xml b/src/test/resources/sitemaps/extension/sitemap-videos-tvshow.xml
new file mode 100644
index 0000000..fbe023a
--- /dev/null
+++ b/src/test/resources/sitemaps/extension/sitemap-videos-tvshow.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
+        xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
+  <url>
+    <video:video>
+      <video:title>My TV Show! - Thu, Jun 05, 2025</video:title>
+      <video:requires_subscription>yes</video:requires_subscription>
+      <video:description>Dummy description</video:description>
+      <video:duration>2459</video:duration>
+      <video:player_loc>https://example.org/video/embed/23-13</video:player_loc>
+      <video:content_loc>https://example.org/video/video/23-13.mp4</video:content_loc>
+      <video:content_segment_loc duration="1287">https://example.org/video/video/23-13-1.mp4</video:content_segment_loc>
+      <video:content_segment_loc duration="1172">https://example.org/video/video/23-13-2.mp4</video:content_segment_loc>
+      <video:category>My TV Show!</video:category>
+      <video:publication_date>2025-06-06T09:00:00+00:00</video:publication_date>
+      <video:expiration_date>2025-06-13T09:00:00+00:00</video:expiration_date>
+      <video:thumbnail_loc>https://example.org/my-tv-show.jpg</video:thumbnail_loc>
+      <video:tag>talkshow</video:tag>
+      <video:tag>interview</video:tag>
+      <video:tag>example</video:tag>
+      <video:tvshow>
+        <video:show_title>My TV Show!</video:show_title>
+        <video:video_type>full</video:video_type>
+        <video:episode_title>Thu, Jun 05, 2025</video:episode_title>
+        <video:season_number>23</video:season_number>
+        <video:episode_number>13</video:episode_number>
+        <video:premier_date>2025-06-05T21:00Z</video:premier_date>
+      </video:tvshow>
+    </video:video>
+    <loc>https://example.org/my-tv-show/23/13/</loc>
+  </url>
+</urlset>