sitemap2atom 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sitemap2atom/__init__.py +22 -0
- sitemap2atom/__main__.py +6 -0
- sitemap2atom/cli.py +83 -0
- sitemap2atom/core.py +330 -0
- sitemap2atom-0.1.0.dist-info/METADATA +130 -0
- sitemap2atom-0.1.0.dist-info/RECORD +9 -0
- sitemap2atom-0.1.0.dist-info/WHEEL +4 -0
- sitemap2atom-0.1.0.dist-info/entry_points.txt +2 -0
- sitemap2atom-0.1.0.dist-info/licenses/LICENSE +21 -0
sitemap2atom/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""sitemap2atom: convert an XML sitemap into an enriched Atom feed."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .core import (
|
|
6
|
+
enrich_atom_entry,
|
|
7
|
+
enrich_url_list_to_atom,
|
|
8
|
+
extract_metadata,
|
|
9
|
+
feed_to_pretty_xml,
|
|
10
|
+
fetch_sitemap_urls,
|
|
11
|
+
parse_metadata,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"enrich_atom_entry",
|
|
17
|
+
"enrich_url_list_to_atom",
|
|
18
|
+
"extract_metadata",
|
|
19
|
+
"feed_to_pretty_xml",
|
|
20
|
+
"fetch_sitemap_urls",
|
|
21
|
+
"parse_metadata",
|
|
22
|
+
]
|
sitemap2atom/__main__.py
ADDED
sitemap2atom/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Command-line interface for sitemap2atom."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .core import (
|
|
11
|
+
DEFAULT_FEED_TITLE,
|
|
12
|
+
enrich_url_list_to_atom,
|
|
13
|
+
feed_to_pretty_xml,
|
|
14
|
+
fetch_sitemap_urls,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command()
|
|
19
|
+
@click.argument("sitemap_url")
|
|
20
|
+
@click.option(
|
|
21
|
+
"-o",
|
|
22
|
+
"--output",
|
|
23
|
+
type=click.Path(dir_okay=False, writable=True),
|
|
24
|
+
default=None,
|
|
25
|
+
help="Write the Atom feed to this file (default: stdout).",
|
|
26
|
+
)
|
|
27
|
+
@click.option(
|
|
28
|
+
"--limit",
|
|
29
|
+
type=int,
|
|
30
|
+
default=None,
|
|
31
|
+
help="Maximum number of sitemap URLs to process (default: all).",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--feed-title",
|
|
35
|
+
default=DEFAULT_FEED_TITLE,
|
|
36
|
+
show_default=True,
|
|
37
|
+
help="Title for the generated Atom feed.",
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--timeout",
|
|
41
|
+
type=int,
|
|
42
|
+
default=10,
|
|
43
|
+
show_default=True,
|
|
44
|
+
help="Per-request timeout in seconds.",
|
|
45
|
+
)
|
|
46
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable info-level logging.")
|
|
47
|
+
@click.version_option(__version__, prog_name="sitemap2atom")
|
|
48
|
+
def main(sitemap_url, output, limit, feed_title, timeout, verbose):
|
|
49
|
+
"""Convert the XML sitemap at SITEMAP_URL into an enriched Atom feed.
|
|
50
|
+
|
|
51
|
+
Each URL in the sitemap is fetched and its OpenGraph/Twitter metadata is
|
|
52
|
+
used to build a rich Atom entry (title, summary, image, author, dates).
|
|
53
|
+
"""
|
|
54
|
+
logging.basicConfig(
|
|
55
|
+
level=logging.INFO if verbose else logging.WARNING,
|
|
56
|
+
format="%(levelname)s: %(message)s",
|
|
57
|
+
stream=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
urls = fetch_sitemap_urls(sitemap_url, timeout=timeout)
|
|
62
|
+
except requests.RequestException as e:
|
|
63
|
+
raise click.ClickException(f"Failed to fetch sitemap {sitemap_url}: {e}")
|
|
64
|
+
|
|
65
|
+
if limit is not None:
|
|
66
|
+
urls = urls[:limit]
|
|
67
|
+
|
|
68
|
+
if not urls:
|
|
69
|
+
raise click.ClickException(f"No <loc> URLs found in sitemap: {sitemap_url}")
|
|
70
|
+
|
|
71
|
+
feed = enrich_url_list_to_atom(urls, feed_title=feed_title, timeout=timeout)
|
|
72
|
+
xml = feed_to_pretty_xml(feed)
|
|
73
|
+
|
|
74
|
+
if output:
|
|
75
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
76
|
+
f.write(xml + "\n")
|
|
77
|
+
click.echo(f"Wrote {output}", err=True)
|
|
78
|
+
else:
|
|
79
|
+
click.echo(xml)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
main()
|
sitemap2atom/core.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Core sitemap-to-Atom conversion logic.
|
|
2
|
+
|
|
3
|
+
The functions here are split so that the HTML/XML parsing is pure (no network)
|
|
4
|
+
and therefore unit-testable, while the network-facing helpers wrap them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from urllib.parse import urljoin, urlparse
|
|
12
|
+
from xml.dom import minidom
|
|
13
|
+
from xml.etree.ElementTree import Element, SubElement, tostring
|
|
14
|
+
|
|
15
|
+
import dateutil.parser
|
|
16
|
+
import requests
|
|
17
|
+
from bs4 import BeautifulSoup
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# A browser-like User-Agent; some sites reject the default requests UA.
|
|
22
|
+
USER_AGENT = (
|
|
23
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
24
|
+
"(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
ATOM_NS = "http://www.w3.org/2005/Atom"
|
|
28
|
+
DEFAULT_FEED_TITLE = "Enriched URL Feed"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_metadata(html, url):
|
|
32
|
+
"""Extract Twitter and OpenGraph metadata from an HTML document.
|
|
33
|
+
|
|
34
|
+
This is the pure, network-free half of :func:`extract_metadata` and can be
|
|
35
|
+
tested against static HTML.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
html (str | bytes): The raw HTML to parse.
|
|
39
|
+
url (str): The URL the HTML came from (used to resolve relative image
|
|
40
|
+
URLs and as a fallback site name).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
dict: Dictionary containing the extracted metadata.
|
|
44
|
+
"""
|
|
45
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
46
|
+
|
|
47
|
+
metadata = {
|
|
48
|
+
"url": url,
|
|
49
|
+
"title": None,
|
|
50
|
+
"description": None,
|
|
51
|
+
"image": None,
|
|
52
|
+
"site_name": None,
|
|
53
|
+
"twitter": {},
|
|
54
|
+
"opengraph": {},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Extract OpenGraph metadata
|
|
58
|
+
# Collect both `og:*` and `article:*` properties. The `og:` prefix is
|
|
59
|
+
# stripped (e.g. og:title -> title), but `article:*` keys are kept intact
|
|
60
|
+
# because enrich_atom_entry() looks them up by their full name
|
|
61
|
+
# (article:published_time, article:modified_time, article:author).
|
|
62
|
+
og_tags = soup.find_all("meta", property=re.compile(r"^(og|article):"))
|
|
63
|
+
for tag in og_tags:
|
|
64
|
+
prop = tag.get("property", "")
|
|
65
|
+
content = tag.get("content", "")
|
|
66
|
+
if not prop or not content:
|
|
67
|
+
continue
|
|
68
|
+
if prop.startswith("og:"):
|
|
69
|
+
prop = prop[len("og:"):]
|
|
70
|
+
metadata["opengraph"][prop] = content
|
|
71
|
+
|
|
72
|
+
# Extract Twitter metadata
|
|
73
|
+
twitter_tags = soup.find_all("meta", attrs={"name": re.compile(r"^twitter:")})
|
|
74
|
+
for tag in twitter_tags:
|
|
75
|
+
name = tag.get("name", "").replace("twitter:", "")
|
|
76
|
+
content = tag.get("content", "")
|
|
77
|
+
if name and content:
|
|
78
|
+
metadata["twitter"][name] = content
|
|
79
|
+
|
|
80
|
+
# Populate main fields from OG or Twitter data
|
|
81
|
+
metadata["title"] = (
|
|
82
|
+
metadata["opengraph"].get("title")
|
|
83
|
+
or metadata["twitter"].get("title")
|
|
84
|
+
or (soup.find("title").get_text().strip() if soup.find("title") else None)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
metadata["description"] = (
|
|
88
|
+
metadata["opengraph"].get("description")
|
|
89
|
+
or metadata["twitter"].get("description")
|
|
90
|
+
or (soup.find("meta", attrs={"name": "description"}) or {}).get("content")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Handle image URLs (make absolute if relative)
|
|
94
|
+
image_url = metadata["opengraph"].get("image") or metadata["twitter"].get("image")
|
|
95
|
+
if image_url:
|
|
96
|
+
metadata["image"] = urljoin(url, image_url)
|
|
97
|
+
|
|
98
|
+
metadata["site_name"] = (
|
|
99
|
+
metadata["opengraph"].get("site_name")
|
|
100
|
+
or metadata["twitter"].get("site")
|
|
101
|
+
or urlparse(url).netloc
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return metadata
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_metadata(url, timeout=10):
|
|
108
|
+
"""Fetch ``url`` and extract Twitter and OpenGraph metadata.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
url (str): The URL to extract metadata from.
|
|
112
|
+
timeout (int): Request timeout in seconds.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
dict: Metadata from :func:`parse_metadata`, or ``{'error': ..., 'url': ...}``
|
|
116
|
+
if the request or parsing fails.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
# Add scheme if missing
|
|
120
|
+
if not url.startswith(("http://", "https://")):
|
|
121
|
+
url = "https://" + url
|
|
122
|
+
|
|
123
|
+
response = requests.get(
|
|
124
|
+
url, headers={"User-Agent": USER_AGENT}, timeout=timeout
|
|
125
|
+
)
|
|
126
|
+
response.raise_for_status()
|
|
127
|
+
|
|
128
|
+
return parse_metadata(response.content, url)
|
|
129
|
+
|
|
130
|
+
except requests.RequestException as e:
|
|
131
|
+
return {"error": f"Request failed: {str(e)}", "url": url}
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return {"error": f"Parsing failed: {str(e)}", "url": url}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _utcnow_iso():
|
|
137
|
+
"""Return the current UTC time as an Atom-friendly ISO 8601 string."""
|
|
138
|
+
return datetime.now().replace(microsecond=0).isoformat() + "Z"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def enrich_atom_entry(metadata, base_entry=None):
|
|
142
|
+
"""Create or enrich an Atom ``<entry>`` element from extracted metadata.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
metadata (dict): Metadata from :func:`extract_metadata`.
|
|
146
|
+
base_entry (Element, optional): Existing entry to enrich.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Element: The Atom entry element.
|
|
150
|
+
"""
|
|
151
|
+
if base_entry is None:
|
|
152
|
+
entry = Element("entry")
|
|
153
|
+
else:
|
|
154
|
+
entry = base_entry
|
|
155
|
+
|
|
156
|
+
# Title
|
|
157
|
+
if metadata.get("title"):
|
|
158
|
+
title_elem = entry.find("title")
|
|
159
|
+
if title_elem is None:
|
|
160
|
+
title_elem = SubElement(entry, "title")
|
|
161
|
+
title_elem.text = metadata["title"]
|
|
162
|
+
title_elem.set("type", "text")
|
|
163
|
+
|
|
164
|
+
# Summary/Description
|
|
165
|
+
if metadata.get("description"):
|
|
166
|
+
summary_elem = entry.find("summary")
|
|
167
|
+
if summary_elem is None:
|
|
168
|
+
summary_elem = SubElement(entry, "summary")
|
|
169
|
+
summary_elem.text = metadata["description"]
|
|
170
|
+
summary_elem.set("type", "text")
|
|
171
|
+
|
|
172
|
+
# Link to original content
|
|
173
|
+
if metadata.get("url"):
|
|
174
|
+
link_elem = SubElement(entry, "link")
|
|
175
|
+
link_elem.set("rel", "alternate")
|
|
176
|
+
link_elem.set("type", "text/html")
|
|
177
|
+
link_elem.set("href", metadata["url"])
|
|
178
|
+
|
|
179
|
+
# Image as enclosure
|
|
180
|
+
if metadata.get("image"):
|
|
181
|
+
enclosure_elem = SubElement(entry, "link")
|
|
182
|
+
enclosure_elem.set("rel", "enclosure")
|
|
183
|
+
# TODO: detect the actual image type instead of assuming JPEG.
|
|
184
|
+
enclosure_elem.set("type", "image/jpeg")
|
|
185
|
+
enclosure_elem.set("href", metadata["image"])
|
|
186
|
+
|
|
187
|
+
# Content type as category
|
|
188
|
+
og_type = metadata.get("opengraph", {}).get("type")
|
|
189
|
+
if og_type:
|
|
190
|
+
category_elem = SubElement(entry, "category")
|
|
191
|
+
category_elem.set("term", og_type)
|
|
192
|
+
category_elem.set("scheme", "http://ogp.me/ns#")
|
|
193
|
+
|
|
194
|
+
# Published date (from article metadata)
|
|
195
|
+
published_time = metadata.get("opengraph", {}).get("article:published_time")
|
|
196
|
+
if published_time:
|
|
197
|
+
try:
|
|
198
|
+
pub_date = dateutil.parser.parse(published_time)
|
|
199
|
+
published_elem = SubElement(entry, "published")
|
|
200
|
+
published_elem.text = pub_date.isoformat()
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
# Updated date
|
|
205
|
+
modified_time = metadata.get("opengraph", {}).get("article:modified_time")
|
|
206
|
+
if modified_time:
|
|
207
|
+
try:
|
|
208
|
+
mod_date = dateutil.parser.parse(modified_time)
|
|
209
|
+
updated_elem = entry.find("updated")
|
|
210
|
+
if updated_elem is None:
|
|
211
|
+
updated_elem = SubElement(entry, "updated")
|
|
212
|
+
updated_elem.text = mod_date.isoformat()
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
# Author (from article metadata)
|
|
217
|
+
author_name = metadata.get("opengraph", {}).get("article:author")
|
|
218
|
+
twitter_creator = metadata.get("twitter", {}).get("creator")
|
|
219
|
+
|
|
220
|
+
# Always add author element (required by Atom spec)
|
|
221
|
+
author_elem = SubElement(entry, "author")
|
|
222
|
+
SubElement(author_elem, "name").text = (
|
|
223
|
+
author_name or twitter_creator or metadata.get("site_name", "Unknown")
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Site name as source - properly structured according to Atom spec
|
|
227
|
+
if metadata.get("site_name"):
|
|
228
|
+
source_elem = SubElement(entry, "source")
|
|
229
|
+
# URI is required
|
|
230
|
+
if metadata.get("url"):
|
|
231
|
+
source_link = SubElement(source_elem, "link")
|
|
232
|
+
source_link.set("rel", "alternate")
|
|
233
|
+
source_link.set("type", "text/html")
|
|
234
|
+
source_link.set("href", metadata["url"])
|
|
235
|
+
|
|
236
|
+
# Required sub-elements for source
|
|
237
|
+
source_title = SubElement(source_elem, "title")
|
|
238
|
+
source_title.text = metadata["site_name"]
|
|
239
|
+
|
|
240
|
+
source_id = SubElement(source_elem, "id")
|
|
241
|
+
source_id.text = "urn:source:" + urlparse(metadata.get("url", "")).netloc
|
|
242
|
+
|
|
243
|
+
source_updated = SubElement(source_elem, "updated")
|
|
244
|
+
source_updated.text = _utcnow_iso()
|
|
245
|
+
|
|
246
|
+
return entry
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def enrich_url_list_to_atom(urls, feed_title=DEFAULT_FEED_TITLE, timeout=10):
|
|
250
|
+
"""Convert a list of URLs into an enriched Atom feed element.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
urls (Iterable[str]): URLs to fetch and enrich.
|
|
254
|
+
feed_title (str): The feed's ``<title>``.
|
|
255
|
+
timeout (int): Per-request timeout in seconds.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Element: The Atom ``<feed>`` root element.
|
|
259
|
+
"""
|
|
260
|
+
# Create feed root
|
|
261
|
+
feed = Element("feed")
|
|
262
|
+
feed.set("xmlns", ATOM_NS)
|
|
263
|
+
|
|
264
|
+
# Feed metadata
|
|
265
|
+
SubElement(feed, "title").text = feed_title
|
|
266
|
+
# Generate a unique UUID for the feed
|
|
267
|
+
SubElement(feed, "id").text = "urn:uuid:" + str(uuid.uuid4())
|
|
268
|
+
SubElement(feed, "updated").text = _utcnow_iso()
|
|
269
|
+
|
|
270
|
+
# Add required self link (required by validators)
|
|
271
|
+
self_link = SubElement(feed, "link")
|
|
272
|
+
self_link.set("rel", "self")
|
|
273
|
+
self_link.set("type", "application/atom+xml")
|
|
274
|
+
self_link.set("href", "file:///enriched_feed.atom")
|
|
275
|
+
|
|
276
|
+
for url in urls:
|
|
277
|
+
metadata = extract_metadata(url, timeout=timeout)
|
|
278
|
+
if "error" in metadata:
|
|
279
|
+
logger.warning("Skipping %s: %s", url, metadata["error"])
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
entry = enrich_atom_entry(metadata)
|
|
283
|
+
# Add required ID and updated if missing
|
|
284
|
+
if entry.find("id") is None:
|
|
285
|
+
id_elem = SubElement(entry, "id")
|
|
286
|
+
id_elem.text = url.strip() # Ensure no whitespace
|
|
287
|
+
if entry.find("updated") is None:
|
|
288
|
+
updated_elem = SubElement(entry, "updated")
|
|
289
|
+
updated_elem.text = _utcnow_iso()
|
|
290
|
+
|
|
291
|
+
feed.append(entry)
|
|
292
|
+
|
|
293
|
+
return feed
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def fetch_sitemap_urls(sitemap_url, timeout=10):
|
|
297
|
+
"""Fetch a sitemap and return the list of ``<loc>`` URLs it contains.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
sitemap_url (str): URL of the XML sitemap.
|
|
301
|
+
timeout (int): Request timeout in seconds.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
list[str]: The URLs found in the sitemap.
|
|
305
|
+
"""
|
|
306
|
+
logger.info("Fetching sitemap: %s", sitemap_url)
|
|
307
|
+
response = requests.get(
|
|
308
|
+
sitemap_url, timeout=timeout, headers={"User-Agent": USER_AGENT}
|
|
309
|
+
)
|
|
310
|
+
response.raise_for_status()
|
|
311
|
+
soup = BeautifulSoup(response.content, "xml")
|
|
312
|
+
urls = [loc.text for loc in soup.find_all("loc")]
|
|
313
|
+
logger.info("Found %d URLs in the sitemap.", len(urls))
|
|
314
|
+
return urls
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def feed_to_pretty_xml(feed):
|
|
318
|
+
"""Serialise an Atom feed element to a pretty-printed XML string.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
feed (Element): The Atom feed element.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
str: Indented, UTF-8 XML with blank lines collapsed.
|
|
325
|
+
"""
|
|
326
|
+
rough_string = tostring(feed, encoding="utf-8")
|
|
327
|
+
pretty = minidom.parseString(rough_string)
|
|
328
|
+
formatted = pretty.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8")
|
|
329
|
+
# Remove the extra blank lines minidom adds
|
|
330
|
+
return "\n".join(line for line in formatted.split("\n") if line.strip())
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sitemap2atom
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tool to convert XML sitemaps to Atom feeds
|
|
5
|
+
Project-URL: homepage, https://github.com/darkflib/sitemap2atom
|
|
6
|
+
Project-URL: repository, https://github.com/darkflib/sitemap2atom
|
|
7
|
+
Project-URL: issues, https://github.com/darkflib/sitemap2atom/issues
|
|
8
|
+
Author-email: Mike Preston <darkflib@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: atom,feed,opengraph,rss,sitemap,syndication
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Topic :: Text Processing :: Markup :: XML
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: beautifulsoup4>=4.9.3
|
|
23
|
+
Requires-Dist: click>=7.1.2
|
|
24
|
+
Requires-Dist: lxml>=4.6.3
|
|
25
|
+
Requires-Dist: python-dateutil>=2.8.1
|
|
26
|
+
Requires-Dist: requests>=2.25.1
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# sitemap2atom
|
|
30
|
+
|
|
31
|
+
A simple tool to convert an XML sitemap into an [Atom](https://datatracker.ietf.org/doc/html/rfc4287)
|
|
32
|
+
feed — especially useful for sites that don't have a CMS, or where the CMS
|
|
33
|
+
doesn't produce a feed. Each URL in the sitemap is fetched and its OpenGraph and
|
|
34
|
+
Twitter Card metadata (title, description, image, author, dates) is used to build
|
|
35
|
+
a rich Atom entry.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
### Run without installing (uvx)
|
|
40
|
+
|
|
41
|
+
Once published to PyPI you can run it directly with
|
|
42
|
+
[uv](https://docs.astral.sh/uv/):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uvx sitemap2atom https://example.com/sitemap.xml -o feed.atom
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
To run the latest code straight from GitHub (before a release, or to try `main`):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uvx --from git+https://github.com/darkflib/sitemap2atom sitemap2atom https://example.com/sitemap.xml
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Install as a tool / library
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv tool install sitemap2atom # installs the `sitemap2atom` command
|
|
58
|
+
# or
|
|
59
|
+
pip install sitemap2atom
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
sitemap2atom SITEMAP_URL [OPTIONS]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
By default the feed is written to standard output; redirect it or use `-o` to
|
|
69
|
+
save it to a file:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Print to stdout
|
|
73
|
+
sitemap2atom https://example.com/sitemap.xml
|
|
74
|
+
|
|
75
|
+
# Write to a file, limiting to the first 20 URLs
|
|
76
|
+
sitemap2atom https://example.com/sitemap.xml -o feed.atom --limit 20
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Options
|
|
80
|
+
|
|
81
|
+
- `-o, --output PATH` — write the Atom feed to this file (default: stdout).
|
|
82
|
+
- `--limit N` — maximum number of sitemap URLs to process (default: all).
|
|
83
|
+
- `--feed-title TEXT` — title for the generated feed (default: `Enriched URL Feed`).
|
|
84
|
+
- `--timeout SECONDS` — per-request timeout in seconds (default: `10`).
|
|
85
|
+
- `-v, --verbose` — enable info-level logging on stderr.
|
|
86
|
+
- `--version` — show the version and exit.
|
|
87
|
+
|
|
88
|
+
### As a library
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from sitemap2atom import fetch_sitemap_urls, enrich_url_list_to_atom, feed_to_pretty_xml
|
|
92
|
+
|
|
93
|
+
urls = fetch_sitemap_urls("https://example.com/sitemap.xml")
|
|
94
|
+
feed = enrich_url_list_to_atom(urls[:10], feed_title="My Feed")
|
|
95
|
+
print(feed_to_pretty_xml(feed))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Example output
|
|
99
|
+
|
|
100
|
+
See this gist for a sample of the kind of enriched Atom feed produced:
|
|
101
|
+
<https://gist.github.com/Darkflib/989b8f3a5a1ea995e8e294669d5e282a>
|
|
102
|
+
|
|
103
|
+
## Limitations
|
|
104
|
+
|
|
105
|
+
This is a simple tool aimed at basic use cases. It does not support
|
|
106
|
+
authentication, sitemap index files / pagination, or dynamic sitemaps, and may
|
|
107
|
+
not handle every sitemap or page format. Treat the sitemap and the pages it
|
|
108
|
+
references as untrusted input and run it against sources you trust.
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
This project uses [uv](https://docs.astral.sh/uv/).
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
git clone https://github.com/darkflib/sitemap2atom.git
|
|
116
|
+
cd sitemap2atom
|
|
117
|
+
uv sync
|
|
118
|
+
uv run pytest
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for more, and
|
|
122
|
+
[CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file
|
|
127
|
+
for details.
|
|
128
|
+
|
|
129
|
+
PS. If you do anything interesting with this code, please let me know! I'd love
|
|
130
|
+
to hear about it.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
sitemap2atom/__init__.py,sha256=j9PPwb-KmQ1X738FtMKaWWtZtVknr-6K1eD7zAsCuGQ,447
|
|
2
|
+
sitemap2atom/__main__.py,sha256=rsK2pRG6HCvqTIbbg4KFBZ9YpDqSDvdaXwglXttcyyU,103
|
|
3
|
+
sitemap2atom/cli.py,sha256=82bl8kNXdeY6sbWl9GPa-63GFpTJs_IUwskxZWPYC2E,2194
|
|
4
|
+
sitemap2atom/core.py,sha256=YDQZQO6Z-8GkM6jMID6enbNBGuo-ixw5HhE7PF94QFo,11120
|
|
5
|
+
sitemap2atom-0.1.0.dist-info/METADATA,sha256=fpEm1aGEUox53g9Rgc52JaW29-CBBHmuJOONUELDN50,4066
|
|
6
|
+
sitemap2atom-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
sitemap2atom-0.1.0.dist-info/entry_points.txt,sha256=hr-tATkjEjaYlqqn1D8zALcDkaDjGnvig-nKBT8wVks,55
|
|
8
|
+
sitemap2atom-0.1.0.dist-info/licenses/LICENSE,sha256=Rb2AZV2we4Key-5FTjEh9ip-0Rao6s5Raj4_vVrxHgk,1069
|
|
9
|
+
sitemap2atom-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mike Preston
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|