SITEMAP GENERATOR
Build XML sitemaps with full standard support — image, video, news, hreflang, sitemap index. Manual entry, bulk import, or site scanner.
GENERATE SITEMAP
SITEMAP STANDARDS
All supported tags, their allowed values, and links to the authoritative specification. Click any row to expand.
<loc> — Page URLThe full URL of the page. Must start with http:// or https://. Must be URL-encoded. Maximum 2,048 characters. Must exactly match the canonical URL Google would index.
<lastmod> — Last Modified DateDate the URL was last modified. Use W3C Datetime format. Providing inaccurate dates may cause Google to ignore the field entirely. Use YYYY-MM-DD for date-only, or YYYY-MM-DDThh:mm:ss+TZ for full datetime.
<changefreq> — Change FrequencyHow frequently the page is likely to change. Used as a hint — crawlers may ignore it. always is for pages that change on every access. never is for archived content.
<priority> — Relative PriorityPriority of this URL relative to other URLs on your site. Range 0.0–1.0. Default is 0.5. Does not affect ranking in search results — only tells the crawler which pages to crawl first. Google has stated it largely ignores this value.
<image:image> — Image ExtensionProvides Google with information about images on your page. Helps images appear in Google Image Search. Add the xmlns:image namespace to the <urlset> tag. Up to 1,000 images per URL.
<video:video> — Video ExtensionProvides metadata about videos on your page for Google Video Search. Requires either video:content_loc or video:player_loc. video:thumbnail_loc, video:title, and video:description are required.
<news:news> — News ExtensionUsed to submit news articles to Google News. Only articles published within the last 48 hours will be crawled. Requires Google News publisher approval. news:publication_date must be in W3C datetime format.
<xhtml:link> — hreflang / Language AlternatesTells search engines about localized versions of your pages. Each URL that has alternates must list all alternate versions including itself. Use hreflang="x-default" for the fallback version shown when no other locale matches.
Valid hreflang values follow BCP 47 language tags: en, en-US, en-GB, fr, fr-CA, zh-Hans, zh-Hant, x-default
<sitemapindex> — Sitemap IndexA sitemap index file lists multiple sitemap files. Each individual sitemap can have at most 50,000 URLs and must be under 50 MB (uncompressed). The index itself can reference up to 50,000 sitemaps. Submit the index URL to Google Search Console.
Google Search Console: Go to Sitemaps → Enter sitemap URL → Submit. Or ping: https://www.google.com/ping?sitemap=YOUR_SITEMAP_URL
Bing/Microsoft: Bing Webmaster Tools → Sitemaps → Submit. Or ping: https://www.bing.com/ping?sitemap=YOUR_SITEMAP_URL
Yandex: Yandex Webmaster → Site Map → Add. Or add to robots.txt: Sitemap: https://example.com/sitemap.xml
Yahoo: Yahoo uses Bing's index — submit to Bing Webmaster Tools.
Best practice: Reference your sitemap in robots.txt so all crawlers discover it automatically: Sitemap: https://example.com/sitemap.xml
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// ── State ──────────────────────────────────────────────────────────────────
let urls = [];
let editingId = null;
let outputFmt = 'xml';
let scanAbort = false;
let idCounter = 0;
function uid() { return 'u' + (++idCounter); }
// ── Tab switching ──────────────────────────────────────────────────────────
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t, i) => {
const names = ['builder', 'bulk', 'scanner'];
t.classList.toggle('active', names[i] === name);
});
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
}
// ── URL Table ──────────────────────────────────────────────────────────────
function renderTable() {
const tbody = document.getElementById('urlTbody');
const table = document.getElementById('urlTable');
const empty = document.getElementById('emptyState');
const countEl = document.getElementById('urlCount');
const warnEl = document.getElementById('urlWarn');
countEl.textContent = urls.length;
warnEl.classList.toggle('hidden', urls.length <= 50000);
if (urls.length === 0) {
table.classList.add('hidden');
empty.classList.remove('hidden');
tbody.innerHTML = '';
return;
}
table.classList.remove('hidden');
empty.classList.add('hidden');
tbody.innerHTML = urls.map((u, i) => {
const badges = [
u.images && u.images.length ? `<span class="ext-badge eb-img">img×${u.images.length}</span>` : '',
u.videos && u.videos.length ? `<span class="ext-badge eb-vid">vid×${u.videos.length}</span>` : '',
u.news && u.news.title ? '<span class="ext-badge eb-news">news</span>' : '',
u.alternates && u.alternates.length ? `<span class="ext-badge eb-lang">lang×${u.alternates.length}</span>` : ''
].filter(Boolean).join('');
return `<tr>
<td style="color:var(--muted);font-size:11px">${i + 1}</td>
<td class="td-loc" title="${esc(u.loc)}">${esc(u.loc) || '<span style="color:var(--muted)">—</span>'}</td>
<td style="font-size:11px;color:var(--muted)">${u.lastmod || '—'}</td>
<td class="td-freq">${u.changefreq || '—'}</td>
<td class="td-pri">${u.priority || '—'}</td>
<td class="td-ext">${badges || '<span style="color:var(--border);font-size:11px">—</span>'}</td>
<td class="td-actions">
<button class="btn-ghost" style="padding:4px 10px;font-size:10px" onclick="openModal('${u.id}')">edit</button>
<button class="btn-danger" onclick="deleteUrl('${u.id}')">✕</button>
</td>
</tr>`;
}).join('');
}
function esc(s) {
if (!s) return '';
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function deleteUrl(id) {
urls = urls.filter(u => u.id !== id);
renderTable();
}
function clearAll() {
if (urls.length === 0) return;
if (!confirm('Clear all ' + urls.length + ' URL(s)?')) return;
urls = [];
renderTable();
}
// ── Modal ──────────────────────────────────────────────────────────────────
function openModal(id) {
editingId = id;
const u = id ? urls.find(u => u.id === id) : null;
document.getElementById('modalTitle').textContent = id ? 'EDIT URL' : 'ADD URL';
// Reset modal tabs
switchModalTab('basic');
// Populate basic fields
document.getElementById('f-loc').value = u ? u.loc : '';
document.getElementById('f-lastmod').value = u ? u.lastmod : '';
document.getElementById('f-changefreq').value = u ? u.changefreq : 'monthly';
document.getElementById('f-priority').value = u ? u.priority : '0.8';
// Images
renderSubList('imagesList', u ? u.images : [], renderImageItem);
// Videos
renderSubList('videosList', u ? u.videos : [], renderVideoItem);
// News
const n = u && u.news ? u.news : {};
document.getElementById('f-news-name').value = n.publication_name || '';
document.getElementById('f-news-lang').value = n.publication_language || '';
document.getElementById('f-news-date').value = n.publication_date ? n.publication_date.replace('Z','').substring(0,16) : '';
document.getElementById('f-news-title').value = n.title || '';
document.getElementById('f-news-keywords').value = n.keywords || '';
// Alternates
renderSubList('alternatesList', u ? u.alternates : [], renderAltItem);
document.getElementById('modalOverlay').classList.remove('hidden');
setTimeout(() => document.getElementById('f-loc').focus(), 50);
}
function closeModal() {
document.getElementById('modalOverlay').classList.add('hidden');
editingId = null;
}
function handleOverlayClick(e) {
if (e.target === document.getElementById('modalOverlay')) closeModal();
}
function switchModalTab(name) {
document.querySelectorAll('.modal-tab').forEach((t, i) => {
const ns = ['basic','images','video','news','hreflang'];
t.classList.toggle('active', ns[i] === name);
});
document.querySelectorAll('.modal-tab-panel').forEach(p => p.classList.remove('active'));
const el = document.getElementById('mtp-' + name);
if (el) el.classList.add('active');
}
function saveUrl() {
const loc = document.getElementById('f-loc').value.trim();
if (!loc) { document.getElementById('f-loc').focus(); return; }
const u = editingId ? urls.find(u => u.id === editingId) : { id: uid(), images: [], videos: [], news: {}, alternates: [] };
u.loc = loc;
u.lastmod = document.getElementById('f-lastmod').value;
u.changefreq = document.getElementById('f-changefreq').value;
u.priority = document.getElementById('f-priority').value;
u.images = collectSubItems('imagesList', collectImage);
u.videos = collectSubItems('videosList', collectVideo);
u.alternates = collectSubItems('alternatesList', collectAlt);
u.news = {
publication_name: document.getElementById('f-news-name').value.trim(),
publication_language: document.getElementById('f-news-lang').value.trim(),
publication_date: document.getElementById('f-news-date').value ? document.getElementById('f-news-date').value + ':00Z' : '',
title: document.getElementById('f-news-title').value.trim(),
keywords: document.getElementById('f-news-keywords').value.trim()
};
if (!editingId) urls.push(u);
closeModal();
renderTable();
}
// ── Sub-list helpers ────────────────────────────────────────────────────────
function renderSubList(containerId, items, renderFn) {
const el = document.getElementById(containerId);
el.innerHTML = '';
(items || []).forEach((item, i) => el.appendChild(renderFn(item, i)));
}
function collectSubItems(containerId, collectFn) {
const items = [];
document.getElementById(containerId).querySelectorAll('.sub-item').forEach(el => {
const item = collectFn(el);
if (item) items.push(item);
});
return items;
}
// Images
function renderImageItem(data, idx) {
const div = document.createElement('div');
div.className = 'sub-item';
div.innerHTML = `
<div class="sub-item-hdr">
<span>Image ${(idx||0)+1}</span>
<button class="btn-danger" onclick="this.closest('.sub-item').remove()">✕ Remove</button>
</div>
<div class="field"><label>image:loc (URL) * <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps#image-loc" target="_blank" rel="noopener">↗</a></label><input type="url" name="img-loc" value="${esc(data&&data.loc||'')}" placeholder="https://example.com/photo.jpg"/></div>
<div class="grid-2">
<div class="field"><label>image:caption <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps#image-caption" target="_blank" rel="noopener">↗</a></label><input type="text" name="img-cap" value="${esc(data&&data.caption||'')}" placeholder="Caption text"/></div>
<div class="field"><label>image:title <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps#image-title" target="_blank" rel="noopener">↗</a></label><input type="text" name="img-title" value="${esc(data&&data.title||'')}" placeholder="Image title"/></div>
</div>
<div class="grid-2">
<div class="field"><label>image:license <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps#image-license" target="_blank" rel="noopener">↗</a></label><input type="url" name="img-lic" value="${esc(data&&data.license||'')}" placeholder="https://creativecommons.org/licenses/by/4.0/"/></div>
<div class="field"><label>image:geo_location <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps#image-geo_location" target="_blank" rel="noopener">↗</a></label><input type="text" name="img-geo" value="${esc(data&&data.geo_location||'')}" placeholder="Appleton, Wisconsin, USA"/></div>
</div>`;
return div;
}
function addImage() {
const list = document.getElementById('imagesList');
list.appendChild(renderImageItem({}, list.children.length));
}
function collectImage(el) {
const loc = el.querySelector('[name="img-loc"]').value.trim();
if (!loc) return null;
return { loc, caption: el.querySelector('[name="img-cap"]').value.trim(), title: el.querySelector('[name="img-title"]').value.trim(), license: el.querySelector('[name="img-lic"]').value.trim(), geo_location: el.querySelector('[name="img-geo"]').value.trim() };
}
// Videos
function renderVideoItem(data, idx) {
const div = document.createElement('div');
div.className = 'sub-item';
div.innerHTML = `
<div class="sub-item-hdr">
<span>Video ${(idx||0)+1}</span>
<button class="btn-danger" onclick="this.closest('.sub-item').remove()">✕ Remove</button>
</div>
<div class="field"><label>video:thumbnail_loc * <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#thumbnail_loc" target="_blank" rel="noopener">↗</a></label><input type="url" name="vid-thumb" value="${esc(data&&data.thumbnail_loc||'')}" placeholder="https://example.com/thumbnail.jpg"/></div>
<div class="grid-2">
<div class="field"><label>video:title * <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#title" target="_blank" rel="noopener">↗</a></label><input type="text" name="vid-title" value="${esc(data&&data.title||'')}" placeholder="Video title"/></div>
<div class="field"><label>video:description * <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#description" target="_blank" rel="noopener">↗</a></label><input type="text" name="vid-desc" value="${esc(data&&data.description||'')}" placeholder="Video description"/></div>
</div>
<div class="grid-2">
<div class="field"><label>video:content_loc <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#content_loc" target="_blank" rel="noopener">↗</a></label><input type="url" name="vid-src" value="${esc(data&&data.content_loc||'')}" placeholder="https://example.com/video.mp4"/></div>
<div class="field"><label>video:player_loc <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#player_loc" target="_blank" rel="noopener">↗</a></label><input type="url" name="vid-player" value="${esc(data&&data.player_loc||'')}" placeholder="https://example.com/player?id=123"/></div>
</div>
<div class="grid-3">
<div class="field"><label>video:duration (s) <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#duration" target="_blank" rel="noopener">↗</a></label><input type="number" name="vid-dur" value="${data&&data.duration||''}" min="1" max="28800" placeholder="360"/></div>
<div class="field"><label>video:publication_date <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#publication_date" target="_blank" rel="noopener">↗</a></label><input type="date" name="vid-pubdate" value="${data&&data.publication_date||''}"/></div>
<div class="field"><label>video:rating (0–5) <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#rating" target="_blank" rel="noopener">↗</a></label><input type="number" name="vid-rating" value="${data&&data.rating||''}" min="0" max="5" step="0.1" placeholder="4.5"/></div>
</div>
<div class="grid-3">
<div class="field"><label>video:family_friendly <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#family_friendly" target="_blank" rel="noopener">↗</a></label><select name="vid-family"><option value="">— none —</option><option value="yes" ${data&&data.family_friendly==='yes'?'selected':''}>yes</option><option value="no" ${data&&data.family_friendly==='no'?'selected':''}>no</option></select></div>
<div class="field"><label>video:live <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#live" target="_blank" rel="noopener">↗</a></label><select name="vid-live"><option value="">— none —</option><option value="yes" ${data&&data.live==='yes'?'selected':''}>yes</option><option value="no" ${data&&data.live==='no'?'selected':''}>no</option></select></div>
<div class="field"><label>video:view_count <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#view_count" target="_blank" rel="noopener">↗</a></label><input type="number" name="vid-views" value="${data&&data.view_count||''}" placeholder="12345"/></div>
</div>
<div class="field"><label>video:tag (comma-separated, max 32) <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#tag" target="_blank" rel="noopener">↗</a></label><input type="text" name="vid-tags" value="${esc(data&&data.tag||'')}" placeholder="tutorial, javascript, webdev"/></div>`;
return div;
}
function addVideo() {
const list = document.getElementById('videosList');
list.appendChild(renderVideoItem({}, list.children.length));
}
function collectVideo(el) {
const thumb = el.querySelector('[name="vid-thumb"]').value.trim();
if (!thumb) return null;
return {
thumbnail_loc: thumb,
title: el.querySelector('[name="vid-title"]').value.trim(),
description: el.querySelector('[name="vid-desc"]').value.trim(),
content_loc: el.querySelector('[name="vid-src"]').value.trim(),
player_loc: el.querySelector('[name="vid-player"]').value.trim(),
duration: el.querySelector('[name="vid-dur"]').value.trim(),
publication_date: el.querySelector('[name="vid-pubdate"]').value.trim(),
rating: el.querySelector('[name="vid-rating"]').value.trim(),
view_count: el.querySelector('[name="vid-views"]').value.trim(),
family_friendly: el.querySelector('[name="vid-family"]').value,
live: el.querySelector('[name="vid-live"]').value,
tag: el.querySelector('[name="vid-tags"]').value.trim()
};
}
// Alternates (hreflang)
function renderAltItem(data, idx) {
const div = document.createElement('div');
div.className = 'sub-item';
div.innerHTML = `
<div class="sub-item-hdr">
<span>Alternate ${(idx||0)+1}</span>
<button class="btn-danger" onclick="this.closest('.sub-item').remove()">✕ Remove</button>
</div>
<div class="grid-2">
<div class="field"><label>hreflang (BCP 47) <a href="https://www.w3.org/International/articles/language-tags/" target="_blank" rel="noopener">↗</a></label><input type="text" name="alt-lang" value="${esc(data&&data.hreflang||'')}" placeholder="en-US" list="langList"/></div>
<div class="field"><label>href (alternate URL) <a href="https://developers.google.com/search/docs/specialty/international/localized-versions" target="_blank" rel="noopener">↗</a></label><input type="url" name="alt-href" value="${esc(data&&data.href||'')}" placeholder="https://example.com/en/"/></div>
</div>`;
return div;
}
function addAlternate() {
const list = document.getElementById('alternatesList');
list.appendChild(renderAltItem({}, list.children.length));
}
function collectAlt(el) {
const lang = el.querySelector('[name="alt-lang"]').value.trim();
const href = el.querySelector('[name="alt-href"]').value.trim();
if (!lang || !href) return null;
return { hreflang: lang, href };
}
// ── Bulk Import ─────────────────────────────────────────────────────────────
function importBulk() {
const raw = document.getElementById('bulkInput').value.trim();
if (!raw) return;
const defFreq = document.getElementById('bulkFreq').value;
const defPri = document.getElementById('bulkPriority').value;
let added = 0;
// Try XML sitemap
if (raw.includes('<urlset') || raw.includes('<sitemapindex')) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(raw, 'text/xml');
const locs = doc.querySelectorAll('loc');
locs.forEach(locEl => {
const loc = locEl.textContent.trim();
if (!loc || loc.endsWith('.xml')) return; // skip sitemap refs in index
const urlEl = locEl.parentElement;
const lastmod = urlEl.querySelector('lastmod') ? urlEl.querySelector('lastmod').textContent.trim() : '';
const changefreq = urlEl.querySelector('changefreq') ? urlEl.querySelector('changefreq').textContent.trim() : defFreq;
const priority = urlEl.querySelector('priority') ? urlEl.querySelector('priority').textContent.trim() : defPri;
urls.push({ id: uid(), loc, lastmod, changefreq, priority, images: [], videos: [], news: {}, alternates: [] });
added++;
});
} catch(e) {}
} else {
// CSV or plain URLs
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
lines.forEach(line => {
if (line.startsWith('#') || line.startsWith('url')) return;
const parts = line.split(',').map(p => p.trim().replace(/^"|"$/g,''));
const loc = parts[0];
if (!loc || !loc.startsWith('http')) return;
const lastmod = parts[1] || '';
const changefreq = parts[2] || defFreq;
const priority = parts[3] || defPri;
urls.push({ id: uid(), loc, lastmod, changefreq, priority, images: [], videos: [], news: {}, alternates: [] });
added++;
});
}
const status = document.getElementById('bulkStatus');
if (added > 0) {
status.innerHTML = `<div class="alert" style="background:rgba(0,200,150,0.07);border:1px solid rgba(0,200,150,0.2);color:var(--success)">✓ Imported ${added} URL(s). Switch to Builder tab to review.</div>`;
renderTable();
} else {
status.innerHTML = `<div class="alert alert-warn">No valid URLs found. Make sure each line starts with http/https, or paste a valid sitemap XML.</div>`;
}
}
// ── Scanner ─────────────────────────────────────────────────────────────────
function logScan(msg, type) {
const log = document.getElementById('scanLog');
const div = document.createElement('div');
div.className = 'log-line' + (type ? ' ' + type : '');
div.textContent = msg;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
}
async function fetchProxy(url) {
const proxy = 'https://api.allorigins.win/get?url=' + encodeURIComponent(url);
const r = await fetch(proxy, { signal: AbortSignal.timeout ? AbortSignal.timeout(10000) : undefined });
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
return data.contents || '';
}
function parseSitemapLocs(xml, base) {
const found = [];
try {
const doc = new DOMParser().parseFromString(xml, 'text/xml');
doc.querySelectorAll('loc').forEach(el => {
const u = el.textContent.trim();
if (u && !u.endsWith('.xml')) found.push(u);
});
} catch(e) {}
return found;
}
function extractLinks(html, origin) {
const links = new Set();
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('a[href]').forEach(a => {
try {
const href = new URL(a.getAttribute('href'), origin).href;
if (href.startsWith(origin) && !/#/.test(href) && !/\.(jpg|jpeg|png|gif|pdf|zip|mp4|svg|ico|css|js|woff|ttf)(\?|$)/i.test(href)) {
links.add(href.split('?')[0].replace(/([^/])$/, '$1'));
}
} catch(e) {}
});
} catch(e) {}
return [...links];
}
let scanFoundUrls = [];
async function startScan() {
const rawUrl = document.getElementById('scanUrl').value.trim();
if (!rawUrl) { document.getElementById('scanUrl').focus(); return; }
let base;
try { base = new URL(rawUrl.startsWith('http') ? rawUrl : 'https://' + rawUrl); }
catch(e) { logScan('Invalid URL', 'err'); return; }
scanAbort = false;
scanFoundUrls = [];
document.getElementById('scanLog').innerHTML = '';
document.getElementById('scanResultsWrap').classList.add('hidden');
document.getElementById('scanBtn').disabled = true;
document.getElementById('scanStopBtn').classList.remove('hidden');
const mode = document.getElementById('scanMode').value;
const maxDepth = parseInt(document.getElementById('scanDepth').value);
const maxUrls = parseInt(document.getElementById('scanMaxUrls').value);
const delay = parseInt(document.getElementById('scanDelay').value) || 0;
const found = new Set();
logScan('Starting scan: ' + base.origin, 'hi');
logScan('CORS proxy: api.allorigins.win', 'hi');
if (mode === 'sitemap') {
// Try robots.txt first
try {
logScan('Fetching robots.txt...');
const robots = await fetchProxy(base.origin + '/robots.txt');
const sitemapUrls = [...robots.matchAll(/^Sitemap:\s*(.+)$/gim)].map(m => m[1].trim());
if (sitemapUrls.length) {
logScan('Found ' + sitemapUrls.length + ' sitemap(s) in robots.txt', 'ok');
for (const su of sitemapUrls) {
if (scanAbort) break;
logScan('Parsing: ' + su);
const xml = await fetchProxy(su);
parseSitemapLocs(xml, base.origin).forEach(u => found.add(u));
}
} else {
logScan('No Sitemap directive in robots.txt');
}
} catch(e) { logScan('robots.txt failed: ' + e.message, 'err'); }
// Try default sitemap.xml
if (found.size === 0 && !scanAbort) {
try {
logScan('Trying /sitemap.xml...');
const xml = await fetchProxy(base.origin + '/sitemap.xml');
parseSitemapLocs(xml, base.origin).forEach(u => found.add(u));
logScan('Found ' + found.size + ' URLs in sitemap.xml', 'ok');
} catch(e) { logScan('/sitemap.xml failed: ' + e.message, 'err'); }
}
// Also try /sitemap_index.xml
if (found.size === 0 && !scanAbort) {
try {
logScan('Trying /sitemap_index.xml...');
const xml = await fetchProxy(base.origin + '/sitemap_index.xml');
const doc = new DOMParser().parseFromString(xml, 'text/xml');
const childSitemaps = [...doc.querySelectorAll('loc')].map(l => l.textContent.trim());
for (const cs of childSitemaps) {
if (scanAbort) break;
logScan('Parsing child sitemap: ' + cs);
const xml2 = await fetchProxy(cs);
parseSitemapLocs(xml2, base.origin).forEach(u => found.add(u));
}
} catch(e) { logScan('/sitemap_index.xml failed: ' + e.message, 'err'); }
}
} else {
// BFS crawl
const visited = new Set();
const queue = [{ url: base.href, depth: 0 }];
while (queue.length > 0 && found.size < maxUrls && !scanAbort) {
const { url, depth } = queue.shift();
const normalized = url.replace(/\/$/, '') + '/';
if (visited.has(normalized)) continue;
visited.add(normalized);
logScan('[d' + depth + '] ' + url);
try {
const html = await fetchProxy(url);
found.add(url);
if (depth < maxDepth) {
const links = extractLinks(html, base.origin);
for (const link of links) {
const n2 = link.replace(/\/$/, '') + '/';
if (!visited.has(n2) && found.size < maxUrls) {
queue.push({ url: link, depth: depth + 1 });
}
}
}
if (delay > 0) await new Promise(r => setTimeout(r, delay));
} catch(e) {
logScan('Failed: ' + url + ' — ' + e.message, 'err');
}
}
}
scanFoundUrls = [...found].slice(0, maxUrls);
logScan('Done. Found ' + scanFoundUrls.length + ' URL(s).', 'ok');
document.getElementById('scanBtn').disabled = false;
document.getElementById('scanStopBtn').classList.add('hidden');
displayScanResults(scanFoundUrls);
}
function stopScan() {
scanAbort = true;
logScan('Stopped by user.', 'err');
document.getElementById('scanBtn').disabled = false;
document.getElementById('scanStopBtn').classList.add('hidden');
}
function displayScanResults(list) {
const wrap = document.getElementById('scanResultsWrap');
const el = document.getElementById('scanResults');
if (!list.length) { wrap.classList.add('hidden'); return; }
el.innerHTML = list.map((u, i) =>
`<div class="scan-res-item">
<input type="checkbox" checked id="sr${i}" data-url="${esc(u)}">
<label class="scan-res-url" for="sr${i}" title="${esc(u)}">${esc(u)}</label>
</div>`
).join('');
wrap.classList.remove('hidden');
}
function selectAllScanResults(val) {
document.getElementById('scanResults').querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = val);
}
function importScanResults() {
const checked = [...document.getElementById('scanResults').querySelectorAll('input[type="checkbox"]:checked')];
const today = new Date().toISOString().slice(0, 10);
let added = 0;
checked.forEach(cb => {
const u = cb.dataset.url;
if (!u) return;
urls.push({ id: uid(), loc: u, lastmod: today, changefreq: 'monthly', priority: '0.8', images: [], videos: [], news: {}, alternates: [] });
added++;
});
if (added) {
switchTab('builder');
renderTable();
logScan('Added ' + added + ' URL(s) to builder.', 'ok');
}
}
// ── Output format ──────────────────────────────────────────────────────────
function setFmt(fmt) {
outputFmt = fmt;
['xml','index','txt','json'].forEach(f => document.getElementById('fmt-' + f).classList.toggle('active', f === fmt));
regenerate();
}
function xmlEsc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
}
function regenerate() {
const out = document.getElementById('output');
if (!urls.length) { out.textContent = ''; return; }
const useImage = document.getElementById('ext-image').checked;
const useVideo = document.getElementById('ext-video').checked;
const useNews = document.getElementById('ext-news').checked;
const useHreflang = document.getElementById('ext-hreflang').checked;
if (outputFmt === 'txt') {
out.textContent = urls.map(u => u.loc).join('\n');
return;
}
if (outputFmt === 'json') {
const data = urls.map(u => {
const obj = { loc: u.loc };
if (u.lastmod) obj.lastmod = u.lastmod;
if (u.changefreq) obj.changefreq = u.changefreq;
if (u.priority) obj.priority = parseFloat(u.priority);
if (useImage && u.images && u.images.length) obj.images = u.images;
if (useVideo && u.videos && u.videos.length) obj.videos = u.videos;
if (useNews && u.news && u.news.title) obj.news = u.news;
if (useHreflang && u.alternates && u.alternates.length) obj.alternates = u.alternates;
return obj;
});
out.textContent = JSON.stringify(data, null, 2);
return;
}
if (outputFmt === 'index') {
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n\n';
urls.forEach(u => {
xml += ' <sitemap>\n';
xml += ` <loc>${xmlEsc(u.loc)}</loc>\n`;
if (u.lastmod) xml += ` <lastmod>${u.lastmod}</lastmod>\n`;
xml += ' </sitemap>\n';
});
xml += '\n</sitemapindex>';
out.textContent = xml;
return;
}
// Standard XML
let ns = 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
if (useImage) ns += '\n xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"';
if (useVideo) ns += '\n xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"';
if (useNews) ns += '\n xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"';
if (useHreflang) ns += '\n xmlns:xhtml="http://www.w3.org/1999/xhtml"';
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<urlset ${ns}>\n`;
urls.forEach(u => {
xml += '\n <url>\n';
xml += ` <loc>${xmlEsc(u.loc)}</loc>\n`;
if (u.lastmod) xml += ` <lastmod>${u.lastmod}</lastmod>\n`;
if (u.changefreq) xml += ` <changefreq>${u.changefreq}</changefreq>\n`;
if (u.priority) xml += ` <priority>${u.priority}</priority>\n`;
// hreflang
if (useHreflang && u.alternates && u.alternates.length) {
u.alternates.forEach(a => {
xml += ` <xhtml:link rel="alternate" hreflang="${xmlEsc(a.hreflang)}" href="${xmlEsc(a.href)}"/>\n`;
});
}
// images
if (useImage && u.images && u.images.length) {
u.images.forEach(img => {
xml += ' <image:image>\n';
xml += ` <image:loc>${xmlEsc(img.loc)}</image:loc>\n`;
if (img.caption) xml += ` <image:caption>${xmlEsc(img.caption)}</image:caption>\n`;
if (img.title) xml += ` <image:title>${xmlEsc(img.title)}</image:title>\n`;
if (img.license) xml += ` <image:license>${xmlEsc(img.license)}</image:license>\n`;
if (img.geo_location) xml += ` <image:geo_location>${xmlEsc(img.geo_location)}</image:geo_location>\n`;
xml += ' </image:image>\n';
});
}
// video
if (useVideo && u.videos && u.videos.length) {
u.videos.forEach(v => {
xml += ' <video:video>\n';
if (v.thumbnail_loc) xml += ` <video:thumbnail_loc>${xmlEsc(v.thumbnail_loc)}</video:thumbnail_loc>\n`;
if (v.title) xml += ` <video:title>${xmlEsc(v.title)}</video:title>\n`;
if (v.description) xml += ` <video:description>${xmlEsc(v.description)}</video:description>\n`;
if (v.content_loc) xml += ` <video:content_loc>${xmlEsc(v.content_loc)}</video:content_loc>\n`;
if (v.player_loc) xml += ` <video:player_loc>${xmlEsc(v.player_loc)}</video:player_loc>\n`;
if (v.duration) xml += ` <video:duration>${v.duration}</video:duration>\n`;
if (v.expiration_date) xml += ` <video:expiration_date>${v.expiration_date}</video:expiration_date>\n`;
if (v.rating) xml += ` <video:rating>${v.rating}</video:rating>\n`;
if (v.view_count) xml += ` <video:view_count>${v.view_count}</video:view_count>\n`;
if (v.publication_date) xml += ` <video:publication_date>${v.publication_date}</video:publication_date>\n`;
if (v.family_friendly) xml += ` <video:family_friendly>${v.family_friendly}</video:family_friendly>\n`;
if (v.live) xml += ` <video:live>${v.live}</video:live>\n`;
if (v.tag) {
v.tag.split(',').forEach(t => { if (t.trim()) xml += ` <video:tag>${xmlEsc(t.trim())}</video:tag>\n`; });
}
xml += ' </video:video>\n';
});
}
// news
if (useNews && u.news && u.news.title) {
const n = u.news;
xml += ' <news:news>\n';
xml += ' <news:publication>\n';
xml += ` <news:name>${xmlEsc(n.publication_name)}</news:name>\n`;
xml += ` <news:language>${xmlEsc(n.publication_language)}</news:language>\n`;
xml += ' </news:publication>\n';
if (n.publication_date) xml += ` <news:publication_date>${n.publication_date}</news:publication_date>\n`;
xml += ` <news:title>${xmlEsc(n.title)}</news:title>\n`;
if (n.keywords) xml += ` <news:keywords>${xmlEsc(n.keywords)}</news:keywords>\n`;
xml += ' </news:news>\n';
}
xml += ' </url>';
});
xml += '\n\n</urlset>';
out.textContent = xml;
}
// ── Copy / Download ─────────────────────────────────────────────────────────
function copyOutput() {
const text = document.getElementById('output').textContent;
if (!text) return;
navigator.clipboard.writeText(text).catch(() => {});
}
function copyOutputBtn(btn) {
const text = document.getElementById('output').textContent;
if (!text) return;
navigator.clipboard.writeText(text).catch(() => {});
btn.textContent = 'copied!';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('copied'); }, 2000);
}
function downloadOutput() {
const text = document.getElementById('output').textContent;
if (!text) return;
const ext = { xml: 'xml', index: 'xml', txt: 'txt', json: 'json' }[outputFmt];
const mime = { xml: 'application/xml', index: 'application/xml', txt: 'text/plain', json: 'application/json' }[outputFmt];
const name = outputFmt === 'index' ? 'sitemap_index.' + ext : 'sitemap.' + ext;
const blob = new Blob([text], { type: mime });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
}
// ── Docs accordion ─────────────────────────────────────────────────────────
function toggleDocs(hdr) {
const body = hdr.nextElementSibling;
body.classList.toggle('open');
}
// ── Sample data ─────────────────────────────────────────────────────────────
function loadSample() {
if (urls.length && !confirm('Replace existing URLs with sample data?')) return;
const today = new Date().toISOString().slice(0,10);
urls = [
{ id: uid(), loc: 'https://example.com/', lastmod: today, changefreq: 'daily', priority: '1.0', images: [], videos: [], news: {}, alternates: [{ hreflang: 'x-default', href: 'https://example.com/' }, { hreflang: 'en', href: 'https://example.com/en/' }] },
{ id: uid(), loc: 'https://example.com/about/', lastmod: today, changefreq: 'monthly', priority: '0.8', images: [{ loc: 'https://example.com/img/team.jpg', caption: 'Our team', title: 'Team Photo', license: '', geo_location: '' }], videos: [], news: {}, alternates: [] },
{ id: uid(), loc: 'https://example.com/blog/intro/', lastmod: today, changefreq: 'weekly', priority: '0.7', images: [], videos: [], news: { publication_name: 'Example Blog', publication_language: 'en', publication_date: today + 'T08:00:00Z', title: 'Getting Started with Sitemaps', keywords: 'SEO, sitemap, XML' }, alternates: [] },
{ id: uid(), loc: 'https://example.com/videos/demo/', lastmod: today, changefreq: 'monthly', priority: '0.7', images: [], videos: [{ thumbnail_loc: 'https://example.com/thumb.jpg', title: 'Product Demo', description: 'See our product in action.', content_loc: 'https://example.com/demo.mp4', player_loc: '', duration: '180', publication_date: today, rating: '', view_count: '', family_friendly: 'yes', live: 'no', tag: 'demo, product' }], news: {}, alternates: [] }
];
renderTable();
regenerate();
}
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});