@webnumseoagent/next 0.1.8 → 0.1.11
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.
- package/dist/sitemap-build.js +71 -5
- package/dist/sitemap-xsl.d.ts +1 -1
- package/dist/sitemap-xsl.js +9 -2
- package/package.json +1 -1
- package/sitemap-build.ts +88 -5
- package/sitemap-xsl.ts +9 -2
package/dist/sitemap-build.js
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.seoWrapSitemap = seoWrapSitemap;
|
|
4
|
+
const API = process.env.SEOAGENT_API_BASE ?? "";
|
|
5
|
+
const SITE = process.env.SEOAGENT_SITE_ID ?? "";
|
|
6
|
+
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
7
|
+
const CFG_DEFAULT = { images: true, excludedGroups: [], excludedRoutes: [] };
|
|
8
|
+
async function fetchSitemapCfg() {
|
|
9
|
+
if (!API || !SITE || !TOKEN)
|
|
10
|
+
return CFG_DEFAULT; // fail-safe = прежнее поведение
|
|
11
|
+
try {
|
|
12
|
+
const r = await fetch(`${API}/api/seo/${SITE}/sitemap-config`, {
|
|
13
|
+
headers: { "x-seo-agent-key": TOKEN },
|
|
14
|
+
next: { revalidate: 300, tags: [`seo-${SITE}`] }, // тот же тег ⇒ пинг ревалидации сбрасывает
|
|
15
|
+
signal: AbortSignal.timeout(5000),
|
|
16
|
+
});
|
|
17
|
+
if (!r.ok)
|
|
18
|
+
return CFG_DEFAULT;
|
|
19
|
+
const j = (await r.json());
|
|
20
|
+
return {
|
|
21
|
+
images: j?.images !== false,
|
|
22
|
+
excludedGroups: Array.isArray(j?.excludedGroups) ? j.excludedGroups.map(String) : [],
|
|
23
|
+
excludedRoutes: Array.isArray(j?.excludedRoutes) ? j.excludedRoutes.map(String) : [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return CFG_DEFAULT;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
4
30
|
function escapeXml(s) {
|
|
5
31
|
return s
|
|
6
32
|
.replace(/&/g, "&")
|
|
@@ -17,11 +43,15 @@ function normalize(entries) {
|
|
|
17
43
|
let lastmod;
|
|
18
44
|
if (e.lastModified)
|
|
19
45
|
lastmod = e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified);
|
|
46
|
+
const images = Array.isArray(e.images)
|
|
47
|
+
? e.images.filter((i) => typeof i === "string" && !!i)
|
|
48
|
+
: [];
|
|
20
49
|
out.push({
|
|
21
50
|
loc: e.url,
|
|
22
51
|
lastmod,
|
|
23
52
|
changefreq: typeof e.changeFrequency === "string" ? e.changeFrequency : undefined,
|
|
24
53
|
priority: typeof e.priority === "number" ? e.priority : undefined,
|
|
54
|
+
images: images.length ? images : undefined,
|
|
25
55
|
});
|
|
26
56
|
}
|
|
27
57
|
return out;
|
|
@@ -59,10 +89,22 @@ function groupOfUrl(loc) {
|
|
|
59
89
|
const segs = path.split("/").filter(Boolean);
|
|
60
90
|
return segs.length <= 1 ? "pages" : dec(segs[0]);
|
|
61
91
|
}
|
|
92
|
+
// Нормализованный путь записи (декодированный, без хвостового слэша) — для сравнения
|
|
93
|
+
// с excludedRoutes (точное совпадение пути).
|
|
94
|
+
function pathOf(loc) {
|
|
95
|
+
try {
|
|
96
|
+
return dec(new URL(loc).pathname).replace(/\/+$/, "") || "/";
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return loc;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const normPath = (p) => dec(p).replace(/\/+$/, "") || "/";
|
|
62
103
|
function pi(origin) {
|
|
63
104
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="${escapeXml(origin + "/sitemap.xsl")}"?>\n`;
|
|
64
105
|
}
|
|
65
106
|
function urlsetXml(origin, entries) {
|
|
107
|
+
const hasImages = entries.some((e) => e.images && e.images.length);
|
|
66
108
|
const body = entries
|
|
67
109
|
.map((e) => {
|
|
68
110
|
const parts = [` <loc>${escapeXml(e.loc)}</loc>`];
|
|
@@ -72,10 +114,15 @@ function urlsetXml(origin, entries) {
|
|
|
72
114
|
parts.push(` <changefreq>${escapeXml(e.changefreq)}</changefreq>`);
|
|
73
115
|
if (typeof e.priority === "number")
|
|
74
116
|
parts.push(` <priority>${e.priority}</priority>`);
|
|
117
|
+
for (const img of e.images ?? [])
|
|
118
|
+
parts.push(` <image:image><image:loc>${escapeXml(img)}</image:loc></image:image>`);
|
|
75
119
|
return ` <url>\n${parts.join("\n")}\n </url>`;
|
|
76
120
|
})
|
|
77
121
|
.join("\n");
|
|
78
|
-
|
|
122
|
+
const ns = hasImages
|
|
123
|
+
? `xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"`
|
|
124
|
+
: `xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"`;
|
|
125
|
+
return pi(origin) + `<urlset ${ns}>\n${body}\n</urlset>\n`;
|
|
79
126
|
}
|
|
80
127
|
function indexXml(origin, groups) {
|
|
81
128
|
const body = groups
|
|
@@ -90,6 +137,7 @@ const EMPTY = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www
|
|
|
90
137
|
// urlset, если раздел один); kind="child" — под-карта раздела `group`.
|
|
91
138
|
// Fail-safe: если генератор упал — отдаём валидную пустую карту (не 500).
|
|
92
139
|
async function seoWrapSitemap(fn, kind, group) {
|
|
140
|
+
const cfg = await fetchSitemapCfg();
|
|
93
141
|
let entries;
|
|
94
142
|
try {
|
|
95
143
|
entries = normalize(await fn());
|
|
@@ -100,15 +148,33 @@ async function seoWrapSitemap(fn, kind, group) {
|
|
|
100
148
|
const origin = originOf(entries);
|
|
101
149
|
if (!origin || entries.length === 0)
|
|
102
150
|
return EMPTY;
|
|
151
|
+
// Трансформации применяем к списку записей ОДИН раз, до ветвления по kind —
|
|
152
|
+
// иначе flat и «схлопывание в один раздел» их обходят.
|
|
153
|
+
if (!cfg.images)
|
|
154
|
+
for (const e of entries)
|
|
155
|
+
e.images = undefined; // убрать изображения
|
|
156
|
+
if (cfg.excludedRoutes.length) {
|
|
157
|
+
const ex = new Set(cfg.excludedRoutes.map(normPath));
|
|
158
|
+
entries = entries.filter((e) => !ex.has(pathOf(e.loc))); // исключить отдельные пути
|
|
159
|
+
}
|
|
160
|
+
const excluded = new Set(cfg.excludedGroups);
|
|
103
161
|
// flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
|
|
104
162
|
if (kind === "flat")
|
|
105
|
-
return urlsetXml(origin, entries);
|
|
163
|
+
return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOfUrl(e.loc))));
|
|
106
164
|
if (kind === "child") {
|
|
107
165
|
const g = (group ?? "").replace(/\.xml$/i, "");
|
|
166
|
+
if (excluded.has(g))
|
|
167
|
+
return EMPTY; // раздел выключен → пустая (валидная) карта
|
|
108
168
|
return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
|
|
109
169
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
170
|
+
// index
|
|
171
|
+
const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort().filter((g) => !excluded.has(g));
|
|
172
|
+
if (groups.length === 0)
|
|
173
|
+
return EMPTY; // всё выключено → пустая карта (backstop)
|
|
174
|
+
if (groups.length === 1) {
|
|
175
|
+
// остался один раздел → отдаём его urlset (только его записи, не все!)
|
|
176
|
+
const only = groups[0];
|
|
177
|
+
return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === only));
|
|
178
|
+
}
|
|
113
179
|
return indexXml(origin, groups);
|
|
114
180
|
}
|
package/dist/sitemap-xsl.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SITEMAP_XSL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xsl:stylesheet version=\"1.0\"\n xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"\n xmlns:s=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n<xsl:output method=\"html\" encoding=\"UTF-8\" indent=\"yes\"/>\n<xsl:template match=\"/\">\n<html lang=\"ru\">\n<head>\n<meta charset=\"UTF-8\"/>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n<title>XML-\u043A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430</title>\n<style>\n *{box-sizing:border-box}\n body{font-family:-apple-system,system-ui,'Segoe UI',Roboto,sans-serif;color:#1e293b;margin:0;background:#f8fafc}\n .head{background:#2563eb;color:#fff;padding:28px 40px}\n .head h1{margin:0 0 8px;font-size:26px;font-weight:600}\n .head p{margin:0;font-size:14px;opacity:.92;max-width:920px;line-height:1.55}\n .head a{color:#fff}\n .wrap{padding:24px 40px}\n .count{color:#475569;font-size:14px;margin:0 0 14px}\n table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}\n th{background:#eff2f7;color:#334155;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.03em;padding:12px 16px}\n td{padding:11px 16px;font-size:14px;border-top:1px solid #f1f5f9}\n tr:hover td{background:#f8fafc}\n td a{color:#2563eb;text-decoration:none;word-break:break-all}\n td a:hover{text-decoration:underline}\n .n{width:80px;color:#64748b;text-align:right}\n .d{width:140px;color:#64748b;white-space:nowrap}\n</style>\n</head>\n<body>\n<div class=\"head\">\n <h1>XML-\u043A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430</h1>\n <p>\u0421\u043F\u0438\u0441\u043E\u043A \u0441\u0442\u0440\u0430\u043D\u0438\u0446 \u0434\u043B\u044F \u043F\u043E\u0438\u0441\u043A\u043E\u0432\u044B\u0445 \u0441\u0438\u0441\u0442\u0435\u043C. \u0421\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043E \u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u043E\u0439 <b>SEO Agent</b>. \u041F\u043E\u0434\u0440\u043E\u0431\u043D\u0435\u0435 \u043E\u0431 <a href=\"https://www.sitemaps.org/\">XML-\u043A\u0430\u0440\u0442\u0430\u0445 \u0441\u0430\u0439\u0442\u0430</a>.</p>\n</div>\n<div class=\"wrap\">\n<xsl:choose>\n <xsl:when test=\"s:sitemapindex\">\n <p class=\"count\">\u0418\u043D\u0434\u0435\u043A\u0441\u043D\u044B\u0439 \u0444\u0430\u0439\u043B \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 <b><xsl:value-of select=\"count(s:sitemapindex/s:sitemap)\"/></b> \u043A\u0430\u0440\u0442(\u044B) \u0441\u0430\u0439\u0442\u0430.</p>\n <table>\n <tr><th>\u041A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430</th></tr>\n <xsl:for-each select=\"s:sitemapindex/s:sitemap\">\n <tr><td><a href=\"{s:loc}\"><xsl:value-of select=\"s:loc\"/></a></td></tr>\n </xsl:for-each>\n </table>\n </xsl:when>\n <xsl:otherwise>\n <p class=\"count\">\u042D\u0442\u0430 \u043A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430 \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 <b><xsl:value-of select=\"count(s:urlset/s:url)\"/></b> URL.</p>\n <table>\n <tr><th class=\"n\">#</th><th>URL</th><th class=\"d\">\u041F\u043E\u0441\u043B\u0435\u0434\u043D\u0435\u0435 \u0438\u0437\u043C.</th></tr>\n <xsl:for-each select=\"s:urlset/s:url\">\n <tr>\n <td class=\"n\"><xsl:value-of select=\"position()\"/></td>\n <td><a href=\"{s:loc}\"><xsl:value-of select=\"s:loc\"/></a></td>\n <td class=\"d\"><xsl:value-of select=\"substring(s:lastmod,1,10)\"/></td>\n </tr>\n </xsl:for-each>\n </table>\n </xsl:otherwise>\n</xsl:choose>\n</div>\n</body>\n</html>\n</xsl:template>\n</xsl:stylesheet>\n";
|
|
1
|
+
export declare const SITEMAP_XSL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xsl:stylesheet version=\"1.0\"\n xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"\n xmlns:s=\"http://www.sitemaps.org/schemas/sitemap/0.9\"\n xmlns:image=\"http://www.google.com/schemas/sitemap-image/1.1\">\n<xsl:output method=\"html\" encoding=\"UTF-8\" indent=\"yes\"/>\n<xsl:template match=\"/\">\n<html lang=\"ru\">\n<head>\n<meta charset=\"UTF-8\"/>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n<title>XML-\u043A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430</title>\n<style>\n *{box-sizing:border-box}\n body{font-family:-apple-system,system-ui,'Segoe UI',Roboto,sans-serif;color:#1e293b;margin:0;background:#f8fafc}\n .head{background:#2563eb;color:#fff;padding:28px 40px}\n .head h1{margin:0 0 8px;font-size:26px;font-weight:600}\n .head p{margin:0;font-size:14px;opacity:.92;max-width:920px;line-height:1.55}\n .head a{color:#fff}\n .wrap{padding:24px 40px}\n .count{color:#475569;font-size:14px;margin:0 0 14px}\n .back{margin:0 0 12px}\n .back a{color:#2563eb;text-decoration:none;font-size:14px;font-weight:500}\n .back a:hover{text-decoration:underline}\n table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}\n th{background:#eff2f7;color:#334155;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.03em;padding:12px 16px}\n td{padding:11px 16px;font-size:14px;border-top:1px solid #f1f5f9}\n tr:hover td{background:#f8fafc}\n td a{color:#2563eb;text-decoration:none;word-break:break-all}\n td a:hover{text-decoration:underline}\n .n{width:80px;color:#64748b;text-align:right}\n .i{width:120px;color:#64748b;text-align:center}\n .d{width:140px;color:#64748b;white-space:nowrap}\n</style>\n</head>\n<body>\n<div class=\"head\">\n <h1>XML-\u043A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430</h1>\n <p>\u0421\u043F\u0438\u0441\u043E\u043A \u0441\u0442\u0440\u0430\u043D\u0438\u0446 \u0434\u043B\u044F \u043F\u043E\u0438\u0441\u043A\u043E\u0432\u044B\u0445 \u0441\u0438\u0441\u0442\u0435\u043C. \u0421\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043E \u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u043E\u0439 <b>SEO Agent</b>. \u041F\u043E\u0434\u0440\u043E\u0431\u043D\u0435\u0435 \u043E\u0431 <a href=\"https://www.sitemaps.org/\">XML-\u043A\u0430\u0440\u0442\u0430\u0445 \u0441\u0430\u0439\u0442\u0430</a>.</p>\n</div>\n<div class=\"wrap\">\n<xsl:choose>\n <xsl:when test=\"s:sitemapindex\">\n <p class=\"count\">\u0418\u043D\u0434\u0435\u043A\u0441\u043D\u044B\u0439 \u0444\u0430\u0439\u043B \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 <b><xsl:value-of select=\"count(s:sitemapindex/s:sitemap)\"/></b> \u043A\u0430\u0440\u0442(\u044B) \u0441\u0430\u0439\u0442\u0430.</p>\n <table>\n <tr><th>\u041A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430</th></tr>\n <xsl:for-each select=\"s:sitemapindex/s:sitemap\">\n <tr><td><a href=\"{s:loc}\"><xsl:value-of select=\"s:loc\"/></a></td></tr>\n </xsl:for-each>\n </table>\n </xsl:when>\n <xsl:otherwise>\n <p class=\"back\"><a href=\"/sitemap.xml\">\u2190 \u0418\u043D\u0434\u0435\u043A\u0441 \u043A\u0430\u0440\u0442\u044B \u0441\u0430\u0439\u0442\u0430</a></p>\n <p class=\"count\">\u042D\u0442\u0430 \u043A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430 \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 <b><xsl:value-of select=\"count(s:urlset/s:url)\"/></b> URL.</p>\n <table>\n <tr><th class=\"n\">#</th><th>URL</th><th class=\"i\">\u0418\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u044F</th><th class=\"d\">\u041F\u043E\u0441\u043B\u0435\u0434\u043D\u0435\u0435 \u0438\u0437\u043C.</th></tr>\n <xsl:for-each select=\"s:urlset/s:url\">\n <tr>\n <td class=\"n\"><xsl:value-of select=\"position()\"/></td>\n <td><a href=\"{s:loc}\"><xsl:value-of select=\"s:loc\"/></a></td>\n <td class=\"i\"><xsl:value-of select=\"count(image:image)\"/></td>\n <td class=\"d\"><xsl:value-of select=\"substring(s:lastmod,1,10)\"/></td>\n </tr>\n </xsl:for-each>\n </table>\n </xsl:otherwise>\n</xsl:choose>\n</div>\n</body>\n</html>\n</xsl:template>\n</xsl:stylesheet>\n";
|
package/dist/sitemap-xsl.js
CHANGED
|
@@ -6,7 +6,8 @@ exports.SITEMAP_XSL = void 0;
|
|
|
6
6
|
exports.SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
7
7
|
<xsl:stylesheet version="1.0"
|
|
8
8
|
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
|
9
|
-
xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
9
|
+
xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
10
|
+
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
|
10
11
|
<xsl:output method="html" encoding="UTF-8" indent="yes"/>
|
|
11
12
|
<xsl:template match="/">
|
|
12
13
|
<html lang="ru">
|
|
@@ -23,6 +24,9 @@ exports.SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
23
24
|
.head a{color:#fff}
|
|
24
25
|
.wrap{padding:24px 40px}
|
|
25
26
|
.count{color:#475569;font-size:14px;margin:0 0 14px}
|
|
27
|
+
.back{margin:0 0 12px}
|
|
28
|
+
.back a{color:#2563eb;text-decoration:none;font-size:14px;font-weight:500}
|
|
29
|
+
.back a:hover{text-decoration:underline}
|
|
26
30
|
table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
|
|
27
31
|
th{background:#eff2f7;color:#334155;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.03em;padding:12px 16px}
|
|
28
32
|
td{padding:11px 16px;font-size:14px;border-top:1px solid #f1f5f9}
|
|
@@ -30,6 +34,7 @@ exports.SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
30
34
|
td a{color:#2563eb;text-decoration:none;word-break:break-all}
|
|
31
35
|
td a:hover{text-decoration:underline}
|
|
32
36
|
.n{width:80px;color:#64748b;text-align:right}
|
|
37
|
+
.i{width:120px;color:#64748b;text-align:center}
|
|
33
38
|
.d{width:140px;color:#64748b;white-space:nowrap}
|
|
34
39
|
</style>
|
|
35
40
|
</head>
|
|
@@ -50,13 +55,15 @@ exports.SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
50
55
|
</table>
|
|
51
56
|
</xsl:when>
|
|
52
57
|
<xsl:otherwise>
|
|
58
|
+
<p class="back"><a href="/sitemap.xml">← Индекс карты сайта</a></p>
|
|
53
59
|
<p class="count">Эта карта сайта содержит <b><xsl:value-of select="count(s:urlset/s:url)"/></b> URL.</p>
|
|
54
60
|
<table>
|
|
55
|
-
<tr><th class="n">#</th><th>URL</th><th class="d">Последнее изм.</th></tr>
|
|
61
|
+
<tr><th class="n">#</th><th>URL</th><th class="i">Изображения</th><th class="d">Последнее изм.</th></tr>
|
|
56
62
|
<xsl:for-each select="s:urlset/s:url">
|
|
57
63
|
<tr>
|
|
58
64
|
<td class="n"><xsl:value-of select="position()"/></td>
|
|
59
65
|
<td><a href="{s:loc}"><xsl:value-of select="s:loc"/></a></td>
|
|
66
|
+
<td class="i"><xsl:value-of select="count(image:image)"/></td>
|
|
60
67
|
<td class="d"><xsl:value-of select="substring(s:lastmod,1,10)"/></td>
|
|
61
68
|
</tr>
|
|
62
69
|
</xsl:for-each>
|
package/package.json
CHANGED
package/sitemap-build.ts
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
// «Оборачивание» существующей карты сайта: берём данные из ИХ генератора
|
|
2
2
|
// (сохраняя свежесть и все URL), группируем по разделам и отдаём стилизованный
|
|
3
3
|
// индекс + под-карты со ссылкой на XSL. Работает на стороне сайта (без платформы).
|
|
4
|
+
//
|
|
5
|
+
// Плюс тянем настройки карты с платформы (изображения / исключённые разделы и пути) —
|
|
6
|
+
// тем же тегированным fetch, что и robots (вариант B): при сохранении на платформе
|
|
7
|
+
// пинг ревалидации сбрасывает тег seo-<SITE>, и карта пересобирается с новыми правилами.
|
|
8
|
+
// Fail-safe: если платформа недоступна — ведём себя как раньше (всё включено).
|
|
4
9
|
import type { MetadataRoute } from "next";
|
|
5
10
|
|
|
11
|
+
const API = process.env.SEOAGENT_API_BASE ?? "";
|
|
12
|
+
const SITE = process.env.SEOAGENT_SITE_ID ?? "";
|
|
13
|
+
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
14
|
+
|
|
15
|
+
interface SitemapCfg {
|
|
16
|
+
images: boolean;
|
|
17
|
+
excludedGroups: string[];
|
|
18
|
+
excludedRoutes: string[];
|
|
19
|
+
}
|
|
20
|
+
const CFG_DEFAULT: SitemapCfg = { images: true, excludedGroups: [], excludedRoutes: [] };
|
|
21
|
+
|
|
22
|
+
async function fetchSitemapCfg(): Promise<SitemapCfg> {
|
|
23
|
+
if (!API || !SITE || !TOKEN) return CFG_DEFAULT; // fail-safe = прежнее поведение
|
|
24
|
+
try {
|
|
25
|
+
const r = await fetch(`${API}/api/seo/${SITE}/sitemap-config`, {
|
|
26
|
+
headers: { "x-seo-agent-key": TOKEN },
|
|
27
|
+
next: { revalidate: 300, tags: [`seo-${SITE}`] }, // тот же тег ⇒ пинг ревалидации сбрасывает
|
|
28
|
+
signal: AbortSignal.timeout(5000),
|
|
29
|
+
});
|
|
30
|
+
if (!r.ok) return CFG_DEFAULT;
|
|
31
|
+
const j = (await r.json()) as Partial<SitemapCfg> | null;
|
|
32
|
+
return {
|
|
33
|
+
images: j?.images !== false,
|
|
34
|
+
excludedGroups: Array.isArray(j?.excludedGroups) ? j!.excludedGroups!.map(String) : [],
|
|
35
|
+
excludedRoutes: Array.isArray(j?.excludedRoutes) ? j!.excludedRoutes!.map(String) : [],
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
return CFG_DEFAULT;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
function escapeXml(s: string): string {
|
|
7
43
|
return s
|
|
8
44
|
.replace(/&/g, "&")
|
|
@@ -17,19 +53,33 @@ interface NEntry {
|
|
|
17
53
|
lastmod?: string;
|
|
18
54
|
changefreq?: string;
|
|
19
55
|
priority?: number;
|
|
56
|
+
images?: string[];
|
|
20
57
|
}
|
|
21
58
|
|
|
59
|
+
// Запись карты может нести доп. поля (напр. images) сверх типа Next — читаем через каст.
|
|
60
|
+
type RawEntry = {
|
|
61
|
+
url?: unknown;
|
|
62
|
+
lastModified?: string | Date;
|
|
63
|
+
changeFrequency?: string;
|
|
64
|
+
priority?: number;
|
|
65
|
+
images?: unknown;
|
|
66
|
+
};
|
|
67
|
+
|
|
22
68
|
function normalize(entries: MetadataRoute.Sitemap): NEntry[] {
|
|
23
69
|
const out: NEntry[] = [];
|
|
24
|
-
for (const e of entries) {
|
|
70
|
+
for (const e of entries as RawEntry[]) {
|
|
25
71
|
if (!e || typeof e.url !== "string") continue;
|
|
26
72
|
let lastmod: string | undefined;
|
|
27
73
|
if (e.lastModified) lastmod = e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified);
|
|
74
|
+
const images = Array.isArray(e.images)
|
|
75
|
+
? e.images.filter((i: unknown): i is string => typeof i === "string" && !!i)
|
|
76
|
+
: [];
|
|
28
77
|
out.push({
|
|
29
78
|
loc: e.url,
|
|
30
79
|
lastmod,
|
|
31
80
|
changefreq: typeof e.changeFrequency === "string" ? e.changeFrequency : undefined,
|
|
32
81
|
priority: typeof e.priority === "number" ? e.priority : undefined,
|
|
82
|
+
images: images.length ? images : undefined,
|
|
33
83
|
});
|
|
34
84
|
}
|
|
35
85
|
return out;
|
|
@@ -68,21 +118,37 @@ function groupOfUrl(loc: string): string {
|
|
|
68
118
|
return segs.length <= 1 ? "pages" : dec(segs[0]);
|
|
69
119
|
}
|
|
70
120
|
|
|
121
|
+
// Нормализованный путь записи (декодированный, без хвостового слэша) — для сравнения
|
|
122
|
+
// с excludedRoutes (точное совпадение пути).
|
|
123
|
+
function pathOf(loc: string): string {
|
|
124
|
+
try {
|
|
125
|
+
return dec(new URL(loc).pathname).replace(/\/+$/, "") || "/";
|
|
126
|
+
} catch {
|
|
127
|
+
return loc;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const normPath = (p: string): string => dec(p).replace(/\/+$/, "") || "/";
|
|
131
|
+
|
|
71
132
|
function pi(origin: string): string {
|
|
72
133
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="${escapeXml(origin + "/sitemap.xsl")}"?>\n`;
|
|
73
134
|
}
|
|
74
135
|
|
|
75
136
|
function urlsetXml(origin: string, entries: NEntry[]): string {
|
|
137
|
+
const hasImages = entries.some((e) => e.images && e.images.length);
|
|
76
138
|
const body = entries
|
|
77
139
|
.map((e) => {
|
|
78
140
|
const parts = [` <loc>${escapeXml(e.loc)}</loc>`];
|
|
79
141
|
if (e.lastmod) parts.push(` <lastmod>${escapeXml(e.lastmod)}</lastmod>`);
|
|
80
142
|
if (e.changefreq) parts.push(` <changefreq>${escapeXml(e.changefreq)}</changefreq>`);
|
|
81
143
|
if (typeof e.priority === "number") parts.push(` <priority>${e.priority}</priority>`);
|
|
144
|
+
for (const img of e.images ?? []) parts.push(` <image:image><image:loc>${escapeXml(img)}</image:loc></image:image>`);
|
|
82
145
|
return ` <url>\n${parts.join("\n")}\n </url>`;
|
|
83
146
|
})
|
|
84
147
|
.join("\n");
|
|
85
|
-
|
|
148
|
+
const ns = hasImages
|
|
149
|
+
? `xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"`
|
|
150
|
+
: `xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"`;
|
|
151
|
+
return pi(origin) + `<urlset ${ns}>\n${body}\n</urlset>\n`;
|
|
86
152
|
}
|
|
87
153
|
|
|
88
154
|
function indexXml(origin: string, groups: string[]): string {
|
|
@@ -104,6 +170,7 @@ export async function seoWrapSitemap(
|
|
|
104
170
|
kind: "index" | "child" | "flat",
|
|
105
171
|
group?: string,
|
|
106
172
|
): Promise<string> {
|
|
173
|
+
const cfg = await fetchSitemapCfg();
|
|
107
174
|
let entries: NEntry[];
|
|
108
175
|
try {
|
|
109
176
|
entries = normalize(await fn());
|
|
@@ -113,15 +180,31 @@ export async function seoWrapSitemap(
|
|
|
113
180
|
const origin = originOf(entries);
|
|
114
181
|
if (!origin || entries.length === 0) return EMPTY;
|
|
115
182
|
|
|
183
|
+
// Трансформации применяем к списку записей ОДИН раз, до ветвления по kind —
|
|
184
|
+
// иначе flat и «схлопывание в один раздел» их обходят.
|
|
185
|
+
if (!cfg.images) for (const e of entries) e.images = undefined; // убрать изображения
|
|
186
|
+
if (cfg.excludedRoutes.length) {
|
|
187
|
+
const ex = new Set(cfg.excludedRoutes.map(normPath));
|
|
188
|
+
entries = entries.filter((e) => !ex.has(pathOf(e.loc))); // исключить отдельные пути
|
|
189
|
+
}
|
|
190
|
+
const excluded = new Set(cfg.excludedGroups);
|
|
191
|
+
|
|
116
192
|
// flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
|
|
117
|
-
if (kind === "flat") return urlsetXml(origin, entries);
|
|
193
|
+
if (kind === "flat") return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOfUrl(e.loc))));
|
|
118
194
|
|
|
119
195
|
if (kind === "child") {
|
|
120
196
|
const g = (group ?? "").replace(/\.xml$/i, "");
|
|
197
|
+
if (excluded.has(g)) return EMPTY; // раздел выключен → пустая (валидная) карта
|
|
121
198
|
return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
|
|
122
199
|
}
|
|
123
200
|
|
|
124
|
-
|
|
125
|
-
|
|
201
|
+
// index
|
|
202
|
+
const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort().filter((g) => !excluded.has(g));
|
|
203
|
+
if (groups.length === 0) return EMPTY; // всё выключено → пустая карта (backstop)
|
|
204
|
+
if (groups.length === 1) {
|
|
205
|
+
// остался один раздел → отдаём его urlset (только его записи, не все!)
|
|
206
|
+
const only = groups[0];
|
|
207
|
+
return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === only));
|
|
208
|
+
}
|
|
126
209
|
return indexXml(origin, groups);
|
|
127
210
|
}
|
package/sitemap-xsl.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
export const SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4
4
|
<xsl:stylesheet version="1.0"
|
|
5
5
|
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
|
6
|
-
xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
6
|
+
xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
7
|
+
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
|
7
8
|
<xsl:output method="html" encoding="UTF-8" indent="yes"/>
|
|
8
9
|
<xsl:template match="/">
|
|
9
10
|
<html lang="ru">
|
|
@@ -20,6 +21,9 @@ export const SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
20
21
|
.head a{color:#fff}
|
|
21
22
|
.wrap{padding:24px 40px}
|
|
22
23
|
.count{color:#475569;font-size:14px;margin:0 0 14px}
|
|
24
|
+
.back{margin:0 0 12px}
|
|
25
|
+
.back a{color:#2563eb;text-decoration:none;font-size:14px;font-weight:500}
|
|
26
|
+
.back a:hover{text-decoration:underline}
|
|
23
27
|
table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
|
|
24
28
|
th{background:#eff2f7;color:#334155;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.03em;padding:12px 16px}
|
|
25
29
|
td{padding:11px 16px;font-size:14px;border-top:1px solid #f1f5f9}
|
|
@@ -27,6 +31,7 @@ export const SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
27
31
|
td a{color:#2563eb;text-decoration:none;word-break:break-all}
|
|
28
32
|
td a:hover{text-decoration:underline}
|
|
29
33
|
.n{width:80px;color:#64748b;text-align:right}
|
|
34
|
+
.i{width:120px;color:#64748b;text-align:center}
|
|
30
35
|
.d{width:140px;color:#64748b;white-space:nowrap}
|
|
31
36
|
</style>
|
|
32
37
|
</head>
|
|
@@ -47,13 +52,15 @@ export const SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
47
52
|
</table>
|
|
48
53
|
</xsl:when>
|
|
49
54
|
<xsl:otherwise>
|
|
55
|
+
<p class="back"><a href="/sitemap.xml">← Индекс карты сайта</a></p>
|
|
50
56
|
<p class="count">Эта карта сайта содержит <b><xsl:value-of select="count(s:urlset/s:url)"/></b> URL.</p>
|
|
51
57
|
<table>
|
|
52
|
-
<tr><th class="n">#</th><th>URL</th><th class="d">Последнее изм.</th></tr>
|
|
58
|
+
<tr><th class="n">#</th><th>URL</th><th class="i">Изображения</th><th class="d">Последнее изм.</th></tr>
|
|
53
59
|
<xsl:for-each select="s:urlset/s:url">
|
|
54
60
|
<tr>
|
|
55
61
|
<td class="n"><xsl:value-of select="position()"/></td>
|
|
56
62
|
<td><a href="{s:loc}"><xsl:value-of select="s:loc"/></a></td>
|
|
63
|
+
<td class="i"><xsl:value-of select="count(image:image)"/></td>
|
|
57
64
|
<td class="d"><xsl:value-of select="substring(s:lastmod,1,10)"/></td>
|
|
58
65
|
</tr>
|
|
59
66
|
</xsl:for-each>
|