Repository: crawler-commons/crawler-commons
Pull request #439 (2023-10-02 20:31:53)

Bump de.thetaphi:forbiddenapis from 3.5.1 to 3.6
Bumps [de.thetaphi:forbiddenapis](https://github.com/policeman-tools/forbidden-apis) from 3.5.1 to 3.6.
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/policeman-tools/forbidden-apis/commit/fc55fc9664fc1ec370941737436af0e90508761a"><code>fc55fc9</code></a> Switch to release 3.6</li>
<li><a href="https://github.com/policeman-tools/forbidden-apis/commit/60219cc285c5d9e3d341cf67b4feaf12e94cbbd8"><code>60219cc</code></a> Fixup typo in property after ac653c76863d00962edc908e0bddbff6ec22b731</li>
<li><a href="https://github.com/policeman-tools/forbidden-apis/commit/27ea8cd9eaa3839c85ab897392273a356951ad9e"><code>27ea8cd</code></a> Update documentation for commons-io</li>
<li><a href="https://github.com/policeman-tools/forbidden-apis/commit/7eb2e47ddbc6e97b85d8b3b8eaa945de5c9e832d"><code>7eb2e47</code></a> Add support for commons-io 2.14.0 (no changes needed) (<a href="https://redirect.github.com/policeman-tools/forbidden-apis/issues/235">#235</a>)</li>
<li><a href="https://github.com/policeman-tools/forbidden-apis/commit/6df816a594f6bc1bc92e218f3834dfd2ac0da34c"><code>6df816a</code></a> Add missing docs for Java 21</li>
<li><a href="https://github.com/policeman-tools/forbidden-apis/commit/088f03179667d5fc7e8323807c21ddd03ac0ad41"><code>088f031</code></a> Remove java.net.http.HttpResponse$BodySubscribers#ofFile(**) from Java 21 sig...</li>
<li><a href="https://github.com/policeman-tools/forbidden-apis/commit/c32bcc7adb0eaa4a4680c6c562a9de70182ae07c"><code>c32bcc7</code></a> Add Java 21 signatures support</li>
<li><a href="https://github.com/policeman-tools/forbidden-apis/commit/615e13a9c4e57fe9368b3eaa7c091da935eeffad"><code>615e13a</code></a> Update to ASM 9.6 for Java 22 bytecode compatibility</li>
<li><a href="https://github.com/policeman-tools/forbidden-apis/commit/39e9f33d8fb29ab827f256984b48ed9e1ab31fa4"><code>39e9f33</code></a> Support commons-io-unsafe v2.12.0 and v2.13.0 (<a href="https://redirect.github.com/policeman-tools/forbidden-apis/issues/232">#232</a>)</li>
<li><a href="https://github.com/policeman-tools/forbidden-apis/commit/0f301ffef6876772f8868144c2f6e0aad85663b6"><code>0f301ff</code></a> Enable also gradle in the workflow</li>
<li>Additional commits viewable in <a href="https://github.com/policeman-tools/forbidden-apis/compare/3.5.1...3.6">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=de.thetaphi:forbiddenapis&package-manager=maven&previous-version=3.5.1&new-version=3.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/pom.xml b/pom.xml
index 436010d..7fbf094 100644
--- a/pom.xml
+++ b/pom.xml
@@ -224,7 +224,7 @@
 			<plugin>
 				<groupId>de.thetaphi</groupId>
 				<artifactId>forbiddenapis</artifactId>
-				<version>3.5.1</version>
+				<version>3.6</version>
 				<configuration>
 					<!-- if the used Java version is too new, don't fail, just do nothing: -->
 					<failOnUnsupportedJava>false</failOnUnsupportedJava>
@@ -392,8 +392,8 @@
 	<properties>
 		<!-- Dependencies -->
 
-		<commons-io.version>2.13.0</commons-io.version>
-		<slf4j-api.version>2.0.7</slf4j-api.version>
+		<commons-io.version>2.15.0</commons-io.version>
+		<slf4j-api.version>2.0.9</slf4j-api.version>
 		<junit.version>5.9.3</junit.version>
 		<slf4j-log4j12.version>1.7.33</slf4j-log4j12.version>
 
@@ -404,13 +404,13 @@
 		<maven-surfire-plugin.version>3.1.2</maven-surfire-plugin.version>
 		<maven-release-plugin.version>3.0.1</maven-release-plugin.version>
 		<maven-source-plugin.version>3.3.0</maven-source-plugin.version>
-		<maven-javadoc-plugin.version>3.5.0</maven-javadoc-plugin.version>
+		<maven-javadoc-plugin.version>3.6.0</maven-javadoc-plugin.version>
 		<maven-gpg-plugin.version>3.1.0</maven-gpg-plugin.version>
 		<apache-rat-plugin.version>0.8</apache-rat-plugin.version>
 		<maven-assembly-plugin.version>2.2.2</maven-assembly-plugin.version>
 		<maven-deploy-plugin.version>3.1.1</maven-deploy-plugin.version>
 		<checksum-maven-plugin.version>1.4</checksum-maven-plugin.version>
-		<maven.download.plugin.version>1.7.1</maven.download.plugin.version>
+		<maven.download.plugin.version>1.6.8</maven.download.plugin.version>
 
 		<!-- General Properties -->
 		<implementation.build>${scmBranch}@r${buildNumber}</implementation.build>
diff --git a/src/main/java/crawlercommons/sitemaps/Namespace.java b/src/main/java/crawlercommons/sitemaps/Namespace.java
index be554c3..93a1d9a 100644
--- a/src/main/java/crawlercommons/sitemaps/Namespace.java
+++ b/src/main/java/crawlercommons/sitemaps/Namespace.java
@@ -72,6 +72,8 @@ public class Namespace {
 
     public static final String LINKS = "http://www.w3.org/1999/xhtml";
 
+    public static final String PAGEMAPS = "http://www.google.com/schemas/sitemap-pagemap/1.0";
+
     /**
      * In contradiction to the protocol specification ("The Sitemap must ...
      * [s]pecify the namespace (protocol standard) within the &lt;urlset&gt;
@@ -101,6 +103,7 @@ public class Namespace {
         SITEMAP_SUPPORTED_NAMESPACES.addAll(Arrays.asList(VIDEO));
         SITEMAP_SUPPORTED_NAMESPACES.addAll(Arrays.asList(NEWS));
         SITEMAP_SUPPORTED_NAMESPACES.add(LINKS);
+        SITEMAP_SUPPORTED_NAMESPACES.add(PAGEMAPS);
     }
 
     /**
@@ -120,5 +123,6 @@ public class Namespace {
         SITEMAP_EXTENSION_NAMESPACES.put(Extension.VIDEO, Arrays.asList(VIDEO));
         SITEMAP_EXTENSION_NAMESPACES.put(Extension.MOBILE, Arrays.asList(MOBILE));
         SITEMAP_EXTENSION_NAMESPACES.put(Extension.LINKS, Arrays.asList(LINKS));
+        SITEMAP_EXTENSION_NAMESPACES.put(Extension.PAGEMAPS, Arrays.asList(PAGEMAPS));
     }
 }
diff --git a/src/main/java/crawlercommons/sitemaps/extension/Extension.java b/src/main/java/crawlercommons/sitemaps/extension/Extension.java
index 2e8a49a..0d90e7a 100644
--- a/src/main/java/crawlercommons/sitemaps/extension/Extension.java
+++ b/src/main/java/crawlercommons/sitemaps/extension/Extension.java
@@ -44,5 +44,11 @@ public enum Extension {
      * URL as having mobile content</cite>, cf.
      * http://www.google.com/schemas/sitemap-mobile/1.0
      */
-    MOBILE
+    MOBILE,
+    /**
+     * <cite>PageMaps is a structured data format that Google created to enable
+     * website creators to embed data and notes in their webpages.</cite>, cf.
+     * https://support.google.com/programmable-search/answer/1628213
+     */
+    PAGEMAPS
 }
diff --git a/src/main/java/crawlercommons/sitemaps/extension/PageMap.java b/src/main/java/crawlercommons/sitemaps/extension/PageMap.java
new file mode 100644
index 0000000..036f887
--- /dev/null
+++ b/src/main/java/crawlercommons/sitemaps/extension/PageMap.java
@@ -0,0 +1,89 @@
+/**
+ * Copyright 2023 Crawler-Commons
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package crawlercommons.sitemaps.extension;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+
+/**
+ * Data model for the <a href=
+ * "https://support.google.com/programmable-search/answer/1628213">PageMaps</a>
+ * extension to the sitemap protocol used for Google's Programmable Search
+ * Engine.
+ * 
+ * A PageMap holds a list of {@link PageMapDataObject}s, each PageMapDataObject
+ * a map of attributes (pairs of name and value).
+ */
+@SuppressWarnings("serial")
+public class PageMap extends ExtensionMetadata {
+
+    private List<PageMapDataObject> dataObjects = new ArrayList<>();
+
+    public List<PageMapDataObject> getPageMapDataObjects() {
+        return dataObjects;
+    }
+
+    public void addDataObject(PageMapDataObject d) {
+        dataObjects.add(d);
+    }
+
+    @Override
+    public Map<String, String[]> asMap() {
+        Map<String, String[]> map = new LinkedHashMap<>();
+        for (PageMapDataObject dobj : dataObjects) {
+            for (Entry<String, String[]> e : dobj.asMap().entrySet()) {
+                map.put(e.getKey(), e.getValue());
+            }
+        }
+        return map;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("PageMap: [");
+        if (!dataObjects.isEmpty()) {
+            sb.append('\n');
+        }
+        for (PageMapDataObject dobj : dataObjects) {
+            sb.append(dobj.toString()).append(",\n");
+        }
+        sb.append(']');
+        return sb.toString();
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(dataObjects);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj)
+            return true;
+        if (obj == null)
+            return false;
+        if (getClass() != obj.getClass())
+            return false;
+        PageMap other = (PageMap) obj;
+        return Objects.equals(dataObjects, other.dataObjects);
+    }
+
+}
diff --git a/src/main/java/crawlercommons/sitemaps/extension/PageMapDataObject.java b/src/main/java/crawlercommons/sitemaps/extension/PageMapDataObject.java
new file mode 100644
index 0000000..9cb38f2
--- /dev/null
+++ b/src/main/java/crawlercommons/sitemaps/extension/PageMapDataObject.java
@@ -0,0 +1,90 @@
+/**
+ * Copyright 2023 Crawler-Commons
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package crawlercommons.sitemaps.extension;
+
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Map.Entry;
+
+@SuppressWarnings("serial")
+public class PageMapDataObject extends ExtensionMetadata {
+    private String type;
+    private String id;
+    private Map<String, String> attributes;
+
+    public PageMapDataObject(String type, String id) {
+        this.type = type;
+        this.id = id;
+        attributes = new LinkedHashMap<>();
+    }
+
+    public String getType() {
+        return type;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public Map<String, String> getAttributes() {
+        return attributes;
+    }
+
+    public boolean hasAttribute(String name) {
+        return attributes.containsKey(name);
+    }
+
+    public String getAttribute(String name) {
+        return attributes.get(name);
+    }
+
+    public String addAttribute(String name, String value) {
+        return attributes.put(name, value);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj)
+            return true;
+        if (obj == null)
+            return false;
+        if (getClass() != obj.getClass())
+            return false;
+        PageMapDataObject other = (PageMapDataObject) obj;
+        return Objects.equals(attributes, other.attributes) && Objects.equals(id, other.id) && Objects.equals(type, other.type);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{type = ").append(type);
+        sb.append(", id = ").append(id);
+        sb.append(", attributes = ").append(attributes);
+        sb.append("}");
+        return sb.toString();
+    }
+
+    @Override
+    public Map<String, String[]> asMap() {
+        String keyFormat = "%s::%s";
+        String valueFormat = "%s: %s";
+        String key = String.format(Locale.ROOT, keyFormat, (getType() == null ? "" : getType()), (getId() == null ? "" : getId()));
+        String[] values = getAttributes().entrySet().stream().map((Entry<String, String> e) -> String.format(Locale.ROOT, valueFormat, e.getKey(), e.getValue())).toArray(String[]::new);
+        return Map.of(key, values);
+    }
+}
diff --git a/src/main/java/crawlercommons/sitemaps/sax/extension/ExtensionHandler.java b/src/main/java/crawlercommons/sitemaps/sax/extension/ExtensionHandler.java
index 4f4b454..14e4d3a 100644
--- a/src/main/java/crawlercommons/sitemaps/sax/extension/ExtensionHandler.java
+++ b/src/main/java/crawlercommons/sitemaps/sax/extension/ExtensionHandler.java
@@ -55,6 +55,8 @@ public abstract class ExtensionHandler extends DefaultHandler {
                 return new LinksHandler();
             case MOBILE:
                 return new MobileHandler();
+            case PAGEMAPS:
+                return new PageMapsHandler();
             default:
                 return null;
         }
diff --git a/src/main/java/crawlercommons/sitemaps/sax/extension/PageMapsHandler.java b/src/main/java/crawlercommons/sitemaps/sax/extension/PageMapsHandler.java
new file mode 100644
index 0000000..bd3cbc1
--- /dev/null
+++ b/src/main/java/crawlercommons/sitemaps/sax/extension/PageMapsHandler.java
@@ -0,0 +1,119 @@
+/**
+ * Copyright 2023 Crawler-Commons
+ * 
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package crawlercommons.sitemaps.sax.extension;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+
+import crawlercommons.sitemaps.extension.PageMap;
+import crawlercommons.sitemaps.extension.PageMapDataObject;
+
+/**
+ * Handle SAX events in the Google's Programmable Search Engine <a href=
+ * "https://support.google.com/programmable-search/answer/1628213">PageMaps</a>
+ * extension namespace.
+ */
+public class PageMapsHandler extends ExtensionHandler {
+
+    private PageMap currPageMap;
+    private PageMapDataObject currDataObj;
+    private String currAttrName;
+    private StringBuilder currAttrVal = new StringBuilder();
+    private String currAttrValFromAttr;
+
+    public PageMapsHandler() {
+        reset();
+    }
+
+    @Override
+    public void reset() {
+        super.reset();
+        resetCurrent();
+    }
+
+    private void resetCurrent() {
+        currDataObj = null;
+        currAttrName = null;
+        currAttrVal.setLength(0);
+        currAttrValFromAttr = null;
+    }
+
+    @Override
+    public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
+        switch (localName) {
+            case "PageMap":
+            currPageMap = new PageMap();
+                break;
+            case "DataObject":
+            currDataObj = new PageMapDataObject(attributes.getValue("type"), attributes.getValue("id"));
+            if (currPageMap == null) {
+                // ignore lonesome DataObject elements
+            } else {
+                currPageMap.addDataObject(currDataObj);
+            }
+                break;
+            case "Attribute":
+            currAttrVal.setLength(0);
+            currAttrName = attributes.getValue("name");
+            if (attributes.getValue("value") != null) {
+                /*
+                 * The PageMaps specification
+                 * (https://support.google.com/programmable-search/answer/
+                 * 1628213) describes for PageMaps embedded in HTML that the
+                 * attribute value is given as element attribute named "value".
+                 * For sitemaps it should be given as character data. However,
+                 * some PageMaps sitemaps in the wild also use the HTML
+                 * mechanism. We fall back to the HTML mechanism if there is no
+                 * or white space only character data.
+                 */
+                currAttrValFromAttr = attributes.getValue("value");
+            }
+                break;
+        }
+    }
+
+    @Override
+    public void endElement(String uri, String localName, String qName) throws SAXException {
+        switch (localName) {
+            case "PageMap":
+            attributes.add(currPageMap);
+                break;
+            case "DataObject":
+                break;
+            case "Attribute":
+            String currAttrValStr = currAttrVal.toString().trim();
+            if (currDataObj == null) {
+                // ignore lonesome attributes
+            } else if (currAttrValStr.isEmpty() && currAttrValFromAttr != null) {
+                /*
+                 * If there is no or white space only character data, fall back
+                 * to the HTML mechanism and use the content of the attribute
+                 * "value".
+                 */
+                currDataObj.addAttribute(currAttrName, currAttrValFromAttr);
+            } else {
+                currDataObj.addAttribute(currAttrName, currAttrValStr);
+            }
+                break;
+        }
+    }
+
+    @Override
+    public void characters(char[] ch, int start, int length) throws SAXException {
+        currAttrVal.append(String.valueOf(ch, start, length));
+    }
+
+}


Test patch:
diff --git a/src/test/java/crawlercommons/sitemaps/SiteMapParserExtensionTest.java b/src/test/java/crawlercommons/sitemaps/SiteMapParserExtensionTest.java
index 0bc9f50..322530a 100644
--- a/src/test/java/crawlercommons/sitemaps/SiteMapParserExtensionTest.java
+++ b/src/test/java/crawlercommons/sitemaps/SiteMapParserExtensionTest.java
@@ -16,17 +16,32 @@
 
 package crawlercommons.sitemaps;
 
-import crawlercommons.sitemaps.extension.*;
-import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 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;
+import java.util.Map.Entry;
+
+import org.junit.jupiter.api.Test;
 
-import static org.junit.jupiter.api.Assertions.*;
+import crawlercommons.sitemaps.extension.Extension;
+import crawlercommons.sitemaps.extension.ExtensionMetadata;
+import crawlercommons.sitemaps.extension.ImageAttributes;
+import crawlercommons.sitemaps.extension.LinkAttributes;
+import crawlercommons.sitemaps.extension.MobileAttributes;
+import crawlercommons.sitemaps.extension.NewsAttributes;
+import crawlercommons.sitemaps.extension.PageMap;
+import crawlercommons.sitemaps.extension.PageMapDataObject;
+import crawlercommons.sitemaps.extension.VideoAttributes;
 
 public class SiteMapParserExtensionTest {
 
@@ -257,4 +272,56 @@ public class SiteMapParserExtensionTest {
         SiteMap sm = (SiteMap) asm;
         assertEquals(74, sm.getSiteMapUrls().size());
     }
+
+    @Test
+    public void testPageMapSitemap() throws UnknownFormatException, IOException {
+        SiteMapParser parser = new SiteMapParser();
+        parser.setStrictNamespace(true);
+        parser.enableExtension(Extension.PAGEMAPS);
+
+        String urlStr = "http://www.example.com/pagemaps-sitemap.xml";
+        URL url = new URL(urlStr);
+        AbstractSiteMap asm = parse(parser, "src/test/resources/sitemaps/extension/pagemaps-sitemap.xml", url);
+
+        assertEquals(false, asm.isIndex());
+        assertEquals(true, asm instanceof SiteMap);
+        SiteMap sm = (SiteMap) asm;
+        assertEquals(2, sm.getSiteMapUrls().size());
+        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;
+                switch (u.getUrl().toString()) {
+                    case "http://www.example.com/foo":
+                    assertEquals(2, dataObjects.size());
+                    dataObject = dataObjects.get(0);
+                    assertEquals("document", dataObject.getType());
+                    assertEquals("one", dataObject.getId());
+                    assertEquals("Doc One", dataObject.getAttribute("name"));
+                    assertEquals("3.5", dataObject.getAttribute("review"));
+                    dataObject = dataObjects.get(1);
+                    assertEquals("image", dataObject.getType());
+                    assertNull(dataObject.getId());
+                    assertEquals("http://www.example.com/foo.gif", dataObject.getAttribute("image_src"));
+                        break;
+                    case "http://www.example.com/bar":
+                    assertEquals(1, dataObjects.size());
+                    dataObject = dataObjects.get(0);
+                    assertEquals("document", dataObject.getType());
+                    assertEquals("two", dataObject.getId());
+                    assertEquals("Doc Two", dataObject.getAttribute("name"));
+                    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/java/crawlercommons/sitemaps/extension/PageMapsTest.java b/src/test/java/crawlercommons/sitemaps/extension/PageMapsTest.java
new file mode 100644
index 0000000..9a72837
--- /dev/null
+++ b/src/test/java/crawlercommons/sitemaps/extension/PageMapsTest.java
@@ -0,0 +1,71 @@
+/**
+ * Copyright 2023 Crawler-Commons
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package crawlercommons.sitemaps.extension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+
+public class PageMapsTest {
+
+    @Test
+    public void testPageMapAttributesEquals() {
+        PageMap a = new PageMap();
+        PageMapDataObject da = new PageMapDataObject("test", "a");
+        da.addAttribute("foo", "bar");
+        a.addDataObject(da);
+        assertEquals(a, a);
+        assertNotNull(a.toString());
+        assertEquals(1, a.getPageMapDataObjects().size());
+        assertEquals("test", a.getPageMapDataObjects().get(0).getType());
+        assertEquals("a", a.getPageMapDataObjects().get(0).getId());
+        assertEquals("bar", a.getPageMapDataObjects().get(0).getAttribute("foo"));
+
+        PageMap b = new PageMap();
+        PageMapDataObject db = new PageMapDataObject("test", "a");
+        db.addAttribute("foo", "bar");
+        b.addDataObject(db);
+        assertEquals(da, db);
+        assertEquals(a, b);
+        db.addAttribute("hello", "world");
+        assertNotEquals(da, db);
+        assertNotEquals(a, b);
+        assertEquals(b, b);
+        assertNotNull(a.toString());
+        assertEquals(1, b.getPageMapDataObjects().size());
+        assertEquals("test", b.getPageMapDataObjects().get(0).getType());
+        assertEquals("a", b.getPageMapDataObjects().get(0).getId());
+        assertEquals("bar", b.getPageMapDataObjects().get(0).getAttribute("foo"));
+        assertEquals("world", b.getPageMapDataObjects().get(0).getAttribute("hello"));
+        assertEquals(1, b.asMap().size());
+        assertNotNull(b.asMap().get("test::a"));
+        assertEquals(2, b.asMap().get("test::a").length);
+
+        PageMap c = new PageMap();
+        PageMapDataObject dc = new PageMapDataObject("test", "c");
+        dc.addAttribute("abc", "xyz");
+        c.addDataObject(dc);
+        assertEquals(c, c);
+        assertNotEquals(a, c);
+        assertNotNull(a.toString());
+        assertEquals(1, c.getPageMapDataObjects().size());
+        assertEquals("test", c.getPageMapDataObjects().get(0).getType());
+        assertEquals("c", c.getPageMapDataObjects().get(0).getId());
+        assertEquals("xyz", c.getPageMapDataObjects().get(0).getAttribute("abc"));
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/sitemaps/extension/pagemaps-sitemap.xml b/src/test/resources/sitemaps/extension/pagemaps-sitemap.xml
new file mode 100644
index 0000000..5306bde
--- /dev/null
+++ b/src/test/resources/sitemaps/extension/pagemaps-sitemap.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
+        xmlns:content="http://www.google.com/schemas/sitemap-content/1.0">
+  <!-- source: Google's Programmable Search Engine documentation - PageMaps
+       https://support.google.com/programmable-search/answer/1628213
+       and Providing Structured Data
+       https://developers.google.com/custom-search/docs/structured_data
+  -->
+  <url>
+    <loc>http://www.example.com/foo</loc>
+    <PageMap xmlns="http://www.google.com/schemas/sitemap-pagemap/1.0">
+      <DataObject type="document" id="one">
+        <Attribute name="name">Doc One</Attribute>
+        <Attribute name="review">3.5</Attribute>
+      </DataObject>
+      <DataObject type="image">
+        <Attribute name="image_src">http://www.example.com/foo.gif</Attribute>
+      </DataObject>
+    </PageMap>
+  </url>
+  <url>
+    <loc>http://www.example.com/bar</loc>
+    <PageMap xmlns="http://www.google.com/schemas/sitemap-pagemap/1.0">
+      <DataObject type="document" id="two">
+        <Attribute name="name">Doc Two</Attribute>
+        <Attribute name="review">4.0</Attribute>
+      </DataObject>
+    </PageMap>
+  </url>
+</urlset>
\ No newline at end of file