|
1 | | -import { XMLParser } from 'fast-xml-parser'; |
2 | 1 | import { config } from '../config.js'; |
3 | 2 | import { RenderTarget } from './RenderTarget.js'; |
4 | 3 | import { CacheKey } from '../util/cacheKey.js'; |
5 | 4 | import { currentMinuteMs, getNextSitemapRefreshTime } from '../util/time.js'; |
| 5 | +import { parseSitemap } from '../util/sitemap.js'; |
| 6 | +import { configuredStagingIp, dispatcherFor } from '../util/upstream.js'; |
6 | 7 |
|
7 | 8 | class Sitemap extends databases.sitemaps.Sitemap { |
8 | 9 | static directURLMapping = true; |
@@ -202,32 +203,56 @@ function getTtlFromChangeFreq(changefreq, { minTtl, defaultTtl }) { |
202 | 203 | } |
203 | 204 |
|
204 | 205 | async function fetchLatestSitemap(url) { |
| 206 | + // Route every Harper→origin sitemap fetch through the same edge as the render/origin-fetch |
| 207 | + // path: whenever a staging IP is configured, pin the TCP connection to it (Host/SNI stay the |
| 208 | + // real origin, exactly like upstream.js). The security token typically only authenticates |
| 209 | + // against the staging edge, so a direct prod fetch is bounced with a 403 "Access Denied". |
| 210 | + // Empty staging.ip → normal direct fetch (production, once the token is valid at the origin). |
| 211 | + const stagingIp = configuredStagingIp(); |
| 212 | + const via = stagingIp ? ` (via staging ${stagingIp})` : ''; |
| 213 | + |
205 | 214 | const res = await fetch(url, { |
206 | 215 | method: 'GET', |
207 | 216 | redirect: 'follow', |
208 | | - headers: { 'User-Agent': 'harper-bot/1.0', [config.securityToken.header]: config.securityToken.value }, |
| 217 | + headers: { 'User-Agent': config.sitemapUserAgent, [config.securityToken.header]: config.securityToken.value }, |
| 218 | + dispatcher: dispatcherFor(stagingIp), |
209 | 219 | }); |
210 | 220 | const xml = await res.text(); |
211 | 221 |
|
212 | | - const parser = new XMLParser({ |
213 | | - isArray: (tagName) => ['sitemap', 'url'].some((value) => value === tagName), |
214 | | - }); |
215 | | - |
216 | | - const data = parser.parse(xml); |
217 | | - |
218 | | - const parsed = { url, lastRefreshed: new Date(), entries: [], entryCount: 0 }; |
| 222 | + // A blocked/errored fetch returns an HTML error page with a 4xx/5xx status. Guard the |
| 223 | + // status AND the parsed shape so it fails loudly instead of being silently treated as an |
| 224 | + // empty sitemap (which used to return a misleading `created: 0` success). |
| 225 | + if (!res.ok) { |
| 226 | + throw new Error(`Sitemap fetch failed for ${url}${via}: ${res.status} ${res.statusText} — ${snippet(xml)}`); |
| 227 | + } |
219 | 228 |
|
220 | | - if (Array.isArray(data?.urlset?.url)) { |
221 | | - parsed.isIndex = false; |
222 | | - parsed.entries = data.urlset.url; |
223 | | - } else if (Array.isArray(data?.sitemapindex?.sitemap)) { |
224 | | - parsed.isIndex = true; |
225 | | - parsed.entries = data.sitemapindex.sitemap; |
| 229 | + let parsed; |
| 230 | + try { |
| 231 | + parsed = parseSitemap(xml); |
| 232 | + } catch (e) { |
| 233 | + const contentType = res.headers.get('content-type') ?? 'unknown'; |
| 234 | + throw new Error( |
| 235 | + `Sitemap fetch for ${url}${via} returned a non-sitemap response (status ${res.status}, content-type ${contentType}): ${e.message} — ${snippet(xml)}` |
| 236 | + ); |
226 | 237 | } |
227 | 238 |
|
228 | | - parsed.entryCount = parsed.entries.length; |
| 239 | + return { |
| 240 | + url, |
| 241 | + lastRefreshed: new Date(), |
| 242 | + isIndex: parsed.isIndex, |
| 243 | + entries: parsed.entries, |
| 244 | + entryCount: parsed.entries.length, |
| 245 | + }; |
| 246 | +} |
229 | 247 |
|
230 | | - return parsed; |
| 248 | +// A short, single-line excerpt of a response body for error messages. Slice before the |
| 249 | +// whitespace-collapse so a large body (a full sitemap can be >1 MB) doesn't run the regex |
| 250 | +// over the whole string. |
| 251 | +function snippet(body, max = 200) { |
| 252 | + const raw = String(body ?? ''); |
| 253 | + const truncated = raw.length > max * 2 ? raw.slice(0, max * 2) : raw; |
| 254 | + const text = truncated.replace(/\s+/g, ' ').trim(); |
| 255 | + return text.length > max ? `${text.slice(0, max)}…` : text; |
231 | 256 | } |
232 | 257 |
|
233 | 258 | let sitemapSchedulerStarted = false; |
|
0 commit comments