@webnumseoagent/next 0.1.10 → 0.1.12
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/bin/cli.mjs +39 -1
- package/dist/redirects.d.ts +2 -0
- package/dist/redirects.js +161 -0
- package/dist/sitemap-build.js +60 -4
- package/package.json +6 -1
- package/redirects.ts +166 -0
- package/sitemap-build.ts +67 -3
package/bin/cli.mjs
CHANGED
|
@@ -97,7 +97,7 @@ async function init() {
|
|
|
97
97
|
ok("Пакет @webnumseoagent/next установлен — импорт из пакета.");
|
|
98
98
|
} else {
|
|
99
99
|
const vendor = path.join(root, "seoagent");
|
|
100
|
-
for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts"]) {
|
|
100
|
+
for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts", "redirects.ts"]) {
|
|
101
101
|
const dest = path.join(vendor, f);
|
|
102
102
|
if (!(await exists(dest))) {
|
|
103
103
|
await fs.mkdir(vendor, { recursive: true });
|
|
@@ -224,6 +224,44 @@ async function init() {
|
|
|
224
224
|
`import { revalidateTag } from "next/cache";\n\nexport async function POST(req${ext === "ts" ? ": Request" : ""}) {\n if (req.headers.get("x-revalidate-secret") !== process.env.SEOAGENT_REVALIDATE_SECRET) {\n return new Response("unauthorized", { status: 401 });\n }\n revalidateTag(\`seo-\${process.env.SEOAGENT_SITE_ID}\`);\n return Response.json({ ok: true });\n}\n`,
|
|
225
225
|
);
|
|
226
226
|
|
|
227
|
+
// 4.5 middleware (переадресации). Next разрешает ОДИН middleware, а авто-слияние чужого
|
|
228
|
+
// небезопасно → создаём свой, только если своего нет; иначе печатаем ручную инструкцию.
|
|
229
|
+
{
|
|
230
|
+
const MW_MATCHER = '"/((?!api|_next/static|_next/image|favicon.ico|.*\\\\..*).*)"';
|
|
231
|
+
const mwBase = appDir === "src/app" ? "src" : "."; // middleware рядом с app/
|
|
232
|
+
const mwDirAbs = path.join(root, mwBase === "." ? "" : mwBase);
|
|
233
|
+
const existing = [];
|
|
234
|
+
for (const b of [".", "src"]) {
|
|
235
|
+
for (const f of ["middleware.ts", "middleware.js"]) {
|
|
236
|
+
const rel = b === "." ? f : `${b}/${f}`;
|
|
237
|
+
if (await exists(path.join(root, rel))) existing.push(rel);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const redirImport = pkgMode
|
|
241
|
+
? "@webnumseoagent/next/redirects"
|
|
242
|
+
: (() => {
|
|
243
|
+
let rel = path.relative(mwDirAbs, path.join(root, "seoagent")).split(path.sep).join("/");
|
|
244
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
245
|
+
return `${rel}/redirects`;
|
|
246
|
+
})();
|
|
247
|
+
|
|
248
|
+
if (existing.length) {
|
|
249
|
+
log(`\n \x1b[1mmiddleware уже есть (${existing.join(", ")})\x1b[0m — добавь переадресации ВРУЧНУЮ (Next разрешает один middleware):`);
|
|
250
|
+
log(` \x1b[36mimport { seoRedirects } from "${redirImport}";\x1b[0m`);
|
|
251
|
+
log(` 1) первым в функции middleware: \x1b[36mconst r = await seoRedirects(request); if (r) return r;\x1b[0m`);
|
|
252
|
+
log(` 2) добавь в config.matcher: \x1b[36m${MW_MATCHER}\x1b[0m`);
|
|
253
|
+
log(` 3) если middleware делает auth — оставь свою логику scoped (напр. if(!path.startsWith("/admin")) return NextResponse.next()).`);
|
|
254
|
+
log(` \x1b[1mAuth-чувствительно: проверь git diff и вход перед деплоем.\x1b[0m`);
|
|
255
|
+
} else {
|
|
256
|
+
const mwPath = path.join(mwDirAbs, `middleware.${ext}`);
|
|
257
|
+
const body =
|
|
258
|
+
ext === "ts"
|
|
259
|
+
? `import { NextResponse, type NextRequest } from "next/server";\nimport { seoRedirects } from "${redirImport}";\n\nexport async function middleware(request: NextRequest) {\n const redirect = await seoRedirects(request);\n if (redirect) return redirect;\n return NextResponse.next();\n}\n\nexport const config = { matcher: [${MW_MATCHER}] };\n`
|
|
260
|
+
: `import { NextResponse } from "next/server";\nimport { seoRedirects } from "${redirImport}";\n\nexport async function middleware(request) {\n const redirect = await seoRedirects(request);\n if (redirect) return redirect;\n return NextResponse.next();\n}\n\nexport const config = { matcher: [${MW_MATCHER}] };\n`;
|
|
261
|
+
await ensureFile(mwPath, body);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
227
265
|
// 5. .env.local
|
|
228
266
|
const envPath = path.join(root, ".env.local");
|
|
229
267
|
const cur = (await exists(envPath)) ? await fs.readFile(envPath, "utf8") : "";
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.seoRedirects = seoRedirects;
|
|
4
|
+
// Рантайм-переадресации для Next.js middleware (Edge). Тянет активные правила с
|
|
5
|
+
// платформы и применяет к входящему запросу. Отдельный сабпас "@webnumseoagent/next/redirects",
|
|
6
|
+
// чтобы Edge-код не попадал в RSC-бандлы.
|
|
7
|
+
//
|
|
8
|
+
// В Edge middleware Next Data Cache (next:{revalidate,tags}) НЕ работает и revalidateTag
|
|
9
|
+
// сюда не достаёт — поэтому кэшируем правила модульным TTL (переживает жизнь изолята).
|
|
10
|
+
// Правки на платформе применяются в пределах TTL (~минута), не мгновенно.
|
|
11
|
+
//
|
|
12
|
+
// FAIL-SAFE: при любой ошибке / отсутствии env возвращаем null — сайт работает как обычно.
|
|
13
|
+
const server_1 = require("next/server");
|
|
14
|
+
const API = process.env.SEOAGENT_API_BASE ?? "";
|
|
15
|
+
const SITE = process.env.SEOAGENT_SITE_ID ?? "";
|
|
16
|
+
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
17
|
+
const TTL_OK = 60000; // успех — правила живут минуту
|
|
18
|
+
const TTL_FAIL = 10000; // ошибка — быстрее повторить, но не долбить
|
|
19
|
+
const FETCH_TIMEOUT = 2000; // middleware на горячем пути каждого запроса — таймаут короткий
|
|
20
|
+
const MAX_PATTERN = 200;
|
|
21
|
+
const MAX_PATH = 2000;
|
|
22
|
+
const is3xx = (c) => c === 301 || c === 302 || c === 307;
|
|
23
|
+
function normPath(p) {
|
|
24
|
+
let s = p;
|
|
25
|
+
try {
|
|
26
|
+
s = decodeURIComponent(p);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* битый % — оставляем как есть */
|
|
30
|
+
}
|
|
31
|
+
if (s.length > MAX_PATH)
|
|
32
|
+
s = s.slice(0, MAX_PATH);
|
|
33
|
+
if (s.length > 1)
|
|
34
|
+
s = s.replace(/\/+$/, "") || "/";
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
function normSource(v) {
|
|
38
|
+
let s = (v ?? "").trim();
|
|
39
|
+
if (!s)
|
|
40
|
+
return s;
|
|
41
|
+
if (/^https?:\/\//i.test(s))
|
|
42
|
+
return s.replace(/\/+$/, "");
|
|
43
|
+
if (!s.startsWith("/"))
|
|
44
|
+
s = "/" + s;
|
|
45
|
+
return s.length > 1 ? s.replace(/\/+$/, "") : s;
|
|
46
|
+
}
|
|
47
|
+
function compile(rules) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const r of rules) {
|
|
50
|
+
if (!r || r.active === false)
|
|
51
|
+
continue;
|
|
52
|
+
const code = Number(r.code);
|
|
53
|
+
if (is3xx(code) && !r.destination)
|
|
54
|
+
continue; // 3xx без назначения бессмысленно
|
|
55
|
+
if (![301, 302, 307, 410, 451].includes(code))
|
|
56
|
+
continue;
|
|
57
|
+
const tests = [];
|
|
58
|
+
for (const src of r.sources ?? []) {
|
|
59
|
+
const raw = (src?.value ?? "").trim();
|
|
60
|
+
if (!raw || raw.length > MAX_PATTERN)
|
|
61
|
+
continue;
|
|
62
|
+
const ci = src.ignoreCase !== false; // по умолчанию регистронезависимо
|
|
63
|
+
if (src.type === "regex") {
|
|
64
|
+
try {
|
|
65
|
+
const re = new RegExp(raw, ci ? "i" : ""); // компилируем ОДИН раз
|
|
66
|
+
tests.push((p) => re.test(p));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* битый regex — пропускаем только этот источник */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
// exact/start — пути (нормализуем как путь); contains/end — под-строка/суффикс
|
|
74
|
+
// (берём как есть, только trim), иначе навязанный '/' ломает совпадение (".html").
|
|
75
|
+
const norm = src.type === "exact" || src.type === "start" ? normSource(raw) : raw.trim();
|
|
76
|
+
const v = ci ? norm.toLowerCase() : norm;
|
|
77
|
+
const f = (p) => (ci ? p.toLowerCase() : p);
|
|
78
|
+
if (src.type === "exact")
|
|
79
|
+
tests.push((p) => f(p) === v);
|
|
80
|
+
else if (src.type === "contains")
|
|
81
|
+
tests.push((p) => f(p).includes(v));
|
|
82
|
+
else if (src.type === "start")
|
|
83
|
+
tests.push((p) => f(p).startsWith(v));
|
|
84
|
+
else if (src.type === "end")
|
|
85
|
+
tests.push((p) => f(p).endsWith(v));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (tests.length)
|
|
89
|
+
out.push({ test: (p) => tests.some((t) => t(p)), destination: r.destination, code });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
let cache = null;
|
|
94
|
+
let inflight = null;
|
|
95
|
+
async function loadRules() {
|
|
96
|
+
if (!API || !SITE || !TOKEN)
|
|
97
|
+
return null;
|
|
98
|
+
try {
|
|
99
|
+
const r = await fetch(`${API}/api/seo/${SITE}/redirects`, {
|
|
100
|
+
headers: { "x-seo-agent-key": TOKEN },
|
|
101
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
102
|
+
// без next:{revalidate,tags} — в Edge middleware не действуют
|
|
103
|
+
});
|
|
104
|
+
if (!r.ok)
|
|
105
|
+
return null;
|
|
106
|
+
const body = (await r.json());
|
|
107
|
+
const raw = Array.isArray(body) ? body : (body?.rules ?? []);
|
|
108
|
+
return compile(Array.isArray(raw) ? raw : []);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function getRules() {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
if (cache && now - cache.at < cache.ttl)
|
|
117
|
+
return cache.rules;
|
|
118
|
+
if (inflight)
|
|
119
|
+
return inflight; // дедуп конкурентных промахов на холодном изоляте
|
|
120
|
+
inflight = loadRules()
|
|
121
|
+
.then((rules) => {
|
|
122
|
+
cache = { at: Date.now(), ttl: rules === null ? TTL_FAIL : TTL_OK, rules };
|
|
123
|
+
inflight = null;
|
|
124
|
+
return rules;
|
|
125
|
+
})
|
|
126
|
+
.catch(() => {
|
|
127
|
+
inflight = null;
|
|
128
|
+
return cache?.rules ?? null;
|
|
129
|
+
});
|
|
130
|
+
return inflight;
|
|
131
|
+
}
|
|
132
|
+
// Вызывать ПЕРВЫМ в middleware: `const r = await seoRedirects(request); if (r) return r;`
|
|
133
|
+
async function seoRedirects(request) {
|
|
134
|
+
try {
|
|
135
|
+
const rules = await getRules();
|
|
136
|
+
if (!rules || rules.length === 0)
|
|
137
|
+
return null;
|
|
138
|
+
const path = normPath(request.nextUrl.pathname);
|
|
139
|
+
for (const rule of rules) {
|
|
140
|
+
if (!rule.test(path))
|
|
141
|
+
continue;
|
|
142
|
+
if (rule.code === 410 || rule.code === 451) {
|
|
143
|
+
const msg = rule.code === 410 ? "410 Gone" : "451 Unavailable For Legal Reasons";
|
|
144
|
+
return new server_1.NextResponse(msg, { status: rule.code, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
145
|
+
}
|
|
146
|
+
const abs = /^https?:\/\//i.test(rule.destination);
|
|
147
|
+
const target = abs ? new URL(rule.destination) : new URL(rule.destination, request.url);
|
|
148
|
+
// Защита от петли: назначение = текущий путь того же origin — пропускаем.
|
|
149
|
+
if (target.origin === request.nextUrl.origin && normPath(target.pathname) === path)
|
|
150
|
+
continue;
|
|
151
|
+
// Сохраняем входящий query, если у назначения своего нет.
|
|
152
|
+
if (!target.search && request.nextUrl.search)
|
|
153
|
+
target.search = request.nextUrl.search;
|
|
154
|
+
return server_1.NextResponse.redirect(target, rule.code);
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null; // любая ошибка → сайт продолжает работать
|
|
160
|
+
}
|
|
161
|
+
}
|
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, "&")
|
|
@@ -63,6 +89,17 @@ function groupOfUrl(loc) {
|
|
|
63
89
|
const segs = path.split("/").filter(Boolean);
|
|
64
90
|
return segs.length <= 1 ? "pages" : dec(segs[0]);
|
|
65
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(/\/+$/, "") || "/";
|
|
66
103
|
function pi(origin) {
|
|
67
104
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="${escapeXml(origin + "/sitemap.xsl")}"?>\n`;
|
|
68
105
|
}
|
|
@@ -100,6 +137,7 @@ const EMPTY = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www
|
|
|
100
137
|
// urlset, если раздел один); kind="child" — под-карта раздела `group`.
|
|
101
138
|
// Fail-safe: если генератор упал — отдаём валидную пустую карту (не 500).
|
|
102
139
|
async function seoWrapSitemap(fn, kind, group) {
|
|
140
|
+
const cfg = await fetchSitemapCfg();
|
|
103
141
|
let entries;
|
|
104
142
|
try {
|
|
105
143
|
entries = normalize(await fn());
|
|
@@ -110,15 +148,33 @@ async function seoWrapSitemap(fn, kind, group) {
|
|
|
110
148
|
const origin = originOf(entries);
|
|
111
149
|
if (!origin || entries.length === 0)
|
|
112
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);
|
|
113
161
|
// flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
|
|
114
162
|
if (kind === "flat")
|
|
115
|
-
return urlsetXml(origin, entries);
|
|
163
|
+
return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOfUrl(e.loc))));
|
|
116
164
|
if (kind === "child") {
|
|
117
165
|
const g = (group ?? "").replace(/\.xml$/i, "");
|
|
166
|
+
if (excluded.has(g))
|
|
167
|
+
return EMPTY; // раздел выключен → пустая (валидная) карта
|
|
118
168
|
return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
|
|
119
169
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
}
|
|
123
179
|
return indexXml(origin, groups);
|
|
124
180
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webnumseoagent/next",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Runtime SEO adapter + installer for Next.js App Router sites (SEO Agent platform)",
|
|
5
5
|
"main": "dist/client.js",
|
|
6
6
|
"types": "dist/client.d.ts",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"./jsonld": {
|
|
16
16
|
"types": "./dist/jsonld.d.ts",
|
|
17
17
|
"default": "./dist/jsonld.js"
|
|
18
|
+
},
|
|
19
|
+
"./redirects": {
|
|
20
|
+
"types": "./dist/redirects.d.ts",
|
|
21
|
+
"default": "./dist/redirects.js"
|
|
18
22
|
}
|
|
19
23
|
},
|
|
20
24
|
"files": [
|
|
@@ -23,6 +27,7 @@
|
|
|
23
27
|
"jsonld.tsx",
|
|
24
28
|
"sitemap-xsl.ts",
|
|
25
29
|
"sitemap-build.ts",
|
|
30
|
+
"redirects.ts",
|
|
26
31
|
"bin",
|
|
27
32
|
"README.md"
|
|
28
33
|
],
|
package/redirects.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Рантайм-переадресации для Next.js middleware (Edge). Тянет активные правила с
|
|
2
|
+
// платформы и применяет к входящему запросу. Отдельный сабпас "@webnumseoagent/next/redirects",
|
|
3
|
+
// чтобы Edge-код не попадал в RSC-бандлы.
|
|
4
|
+
//
|
|
5
|
+
// В Edge middleware Next Data Cache (next:{revalidate,tags}) НЕ работает и revalidateTag
|
|
6
|
+
// сюда не достаёт — поэтому кэшируем правила модульным TTL (переживает жизнь изолята).
|
|
7
|
+
// Правки на платформе применяются в пределах TTL (~минута), не мгновенно.
|
|
8
|
+
//
|
|
9
|
+
// FAIL-SAFE: при любой ошибке / отсутствии env возвращаем null — сайт работает как обычно.
|
|
10
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
11
|
+
|
|
12
|
+
const API = process.env.SEOAGENT_API_BASE ?? "";
|
|
13
|
+
const SITE = process.env.SEOAGENT_SITE_ID ?? "";
|
|
14
|
+
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
15
|
+
|
|
16
|
+
type MatchType = "exact" | "contains" | "start" | "end" | "regex";
|
|
17
|
+
interface Source {
|
|
18
|
+
value: string;
|
|
19
|
+
type: MatchType;
|
|
20
|
+
ignoreCase?: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface RawRule {
|
|
23
|
+
id?: string;
|
|
24
|
+
sources: Source[];
|
|
25
|
+
destination: string;
|
|
26
|
+
code: number;
|
|
27
|
+
active?: boolean;
|
|
28
|
+
}
|
|
29
|
+
interface CompiledRule {
|
|
30
|
+
test: (path: string) => boolean;
|
|
31
|
+
destination: string;
|
|
32
|
+
code: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TTL_OK = 60_000; // успех — правила живут минуту
|
|
36
|
+
const TTL_FAIL = 10_000; // ошибка — быстрее повторить, но не долбить
|
|
37
|
+
const FETCH_TIMEOUT = 2_000; // middleware на горячем пути каждого запроса — таймаут короткий
|
|
38
|
+
const MAX_PATTERN = 200;
|
|
39
|
+
const MAX_PATH = 2_000;
|
|
40
|
+
|
|
41
|
+
const is3xx = (c: number) => c === 301 || c === 302 || c === 307;
|
|
42
|
+
|
|
43
|
+
function normPath(p: string): string {
|
|
44
|
+
let s = p;
|
|
45
|
+
try {
|
|
46
|
+
s = decodeURIComponent(p);
|
|
47
|
+
} catch {
|
|
48
|
+
/* битый % — оставляем как есть */
|
|
49
|
+
}
|
|
50
|
+
if (s.length > MAX_PATH) s = s.slice(0, MAX_PATH);
|
|
51
|
+
if (s.length > 1) s = s.replace(/\/+$/, "") || "/";
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normSource(v: string): string {
|
|
56
|
+
let s = (v ?? "").trim();
|
|
57
|
+
if (!s) return s;
|
|
58
|
+
if (/^https?:\/\//i.test(s)) return s.replace(/\/+$/, "");
|
|
59
|
+
if (!s.startsWith("/")) s = "/" + s;
|
|
60
|
+
return s.length > 1 ? s.replace(/\/+$/, "") : s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function compile(rules: RawRule[]): CompiledRule[] {
|
|
64
|
+
const out: CompiledRule[] = [];
|
|
65
|
+
for (const r of rules) {
|
|
66
|
+
if (!r || r.active === false) continue;
|
|
67
|
+
const code = Number(r.code);
|
|
68
|
+
if (is3xx(code) && !r.destination) continue; // 3xx без назначения бессмысленно
|
|
69
|
+
if (![301, 302, 307, 410, 451].includes(code)) continue;
|
|
70
|
+
|
|
71
|
+
const tests: Array<(p: string) => boolean> = [];
|
|
72
|
+
for (const src of r.sources ?? []) {
|
|
73
|
+
const raw = (src?.value ?? "").trim();
|
|
74
|
+
if (!raw || raw.length > MAX_PATTERN) continue;
|
|
75
|
+
const ci = src.ignoreCase !== false; // по умолчанию регистронезависимо
|
|
76
|
+
if (src.type === "regex") {
|
|
77
|
+
try {
|
|
78
|
+
const re = new RegExp(raw, ci ? "i" : ""); // компилируем ОДИН раз
|
|
79
|
+
tests.push((p) => re.test(p));
|
|
80
|
+
} catch {
|
|
81
|
+
/* битый regex — пропускаем только этот источник */
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
// exact/start — пути (нормализуем как путь); contains/end — под-строка/суффикс
|
|
85
|
+
// (берём как есть, только trim), иначе навязанный '/' ломает совпадение (".html").
|
|
86
|
+
const norm = src.type === "exact" || src.type === "start" ? normSource(raw) : raw.trim();
|
|
87
|
+
const v = ci ? norm.toLowerCase() : norm;
|
|
88
|
+
const f = (p: string) => (ci ? p.toLowerCase() : p);
|
|
89
|
+
if (src.type === "exact") tests.push((p) => f(p) === v);
|
|
90
|
+
else if (src.type === "contains") tests.push((p) => f(p).includes(v));
|
|
91
|
+
else if (src.type === "start") tests.push((p) => f(p).startsWith(v));
|
|
92
|
+
else if (src.type === "end") tests.push((p) => f(p).endsWith(v));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (tests.length) out.push({ test: (p) => tests.some((t) => t(p)), destination: r.destination, code });
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let cache: { at: number; ttl: number; rules: CompiledRule[] | null } | null = null;
|
|
101
|
+
let inflight: Promise<CompiledRule[] | null> | null = null;
|
|
102
|
+
|
|
103
|
+
async function loadRules(): Promise<CompiledRule[] | null> {
|
|
104
|
+
if (!API || !SITE || !TOKEN) return null;
|
|
105
|
+
try {
|
|
106
|
+
const r = await fetch(`${API}/api/seo/${SITE}/redirects`, {
|
|
107
|
+
headers: { "x-seo-agent-key": TOKEN },
|
|
108
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
109
|
+
// без next:{revalidate,tags} — в Edge middleware не действуют
|
|
110
|
+
});
|
|
111
|
+
if (!r.ok) return null;
|
|
112
|
+
const body = (await r.json()) as { rules?: RawRule[] } | RawRule[] | null;
|
|
113
|
+
const raw = Array.isArray(body) ? body : (body?.rules ?? []);
|
|
114
|
+
return compile(Array.isArray(raw) ? raw : []);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function getRules(): Promise<CompiledRule[] | null> {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
if (cache && now - cache.at < cache.ttl) return cache.rules;
|
|
123
|
+
if (inflight) return inflight; // дедуп конкурентных промахов на холодном изоляте
|
|
124
|
+
inflight = loadRules()
|
|
125
|
+
.then((rules) => {
|
|
126
|
+
cache = { at: Date.now(), ttl: rules === null ? TTL_FAIL : TTL_OK, rules };
|
|
127
|
+
inflight = null;
|
|
128
|
+
return rules;
|
|
129
|
+
})
|
|
130
|
+
.catch(() => {
|
|
131
|
+
inflight = null;
|
|
132
|
+
return cache?.rules ?? null;
|
|
133
|
+
});
|
|
134
|
+
return inflight;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Вызывать ПЕРВЫМ в middleware: `const r = await seoRedirects(request); if (r) return r;`
|
|
138
|
+
export async function seoRedirects(request: NextRequest): Promise<NextResponse | null> {
|
|
139
|
+
try {
|
|
140
|
+
const rules = await getRules();
|
|
141
|
+
if (!rules || rules.length === 0) return null;
|
|
142
|
+
const path = normPath(request.nextUrl.pathname);
|
|
143
|
+
|
|
144
|
+
for (const rule of rules) {
|
|
145
|
+
if (!rule.test(path)) continue;
|
|
146
|
+
|
|
147
|
+
if (rule.code === 410 || rule.code === 451) {
|
|
148
|
+
const msg = rule.code === 410 ? "410 Gone" : "451 Unavailable For Legal Reasons";
|
|
149
|
+
return new NextResponse(msg, { status: rule.code, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const abs = /^https?:\/\//i.test(rule.destination);
|
|
153
|
+
const target = abs ? new URL(rule.destination) : new URL(rule.destination, request.url);
|
|
154
|
+
|
|
155
|
+
// Защита от петли: назначение = текущий путь того же origin — пропускаем.
|
|
156
|
+
if (target.origin === request.nextUrl.origin && normPath(target.pathname) === path) continue;
|
|
157
|
+
|
|
158
|
+
// Сохраняем входящий query, если у назначения своего нет.
|
|
159
|
+
if (!target.search && request.nextUrl.search) target.search = request.nextUrl.search;
|
|
160
|
+
return NextResponse.redirect(target, rule.code);
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null; // любая ошибка → сайт продолжает работать
|
|
165
|
+
}
|
|
166
|
+
}
|
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, "&")
|
|
@@ -82,6 +118,17 @@ function groupOfUrl(loc: string): string {
|
|
|
82
118
|
return segs.length <= 1 ? "pages" : dec(segs[0]);
|
|
83
119
|
}
|
|
84
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
|
+
|
|
85
132
|
function pi(origin: string): string {
|
|
86
133
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="${escapeXml(origin + "/sitemap.xsl")}"?>\n`;
|
|
87
134
|
}
|
|
@@ -123,6 +170,7 @@ export async function seoWrapSitemap(
|
|
|
123
170
|
kind: "index" | "child" | "flat",
|
|
124
171
|
group?: string,
|
|
125
172
|
): Promise<string> {
|
|
173
|
+
const cfg = await fetchSitemapCfg();
|
|
126
174
|
let entries: NEntry[];
|
|
127
175
|
try {
|
|
128
176
|
entries = normalize(await fn());
|
|
@@ -132,15 +180,31 @@ export async function seoWrapSitemap(
|
|
|
132
180
|
const origin = originOf(entries);
|
|
133
181
|
if (!origin || entries.length === 0) return EMPTY;
|
|
134
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
|
+
|
|
135
192
|
// flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
|
|
136
|
-
if (kind === "flat") return urlsetXml(origin, entries);
|
|
193
|
+
if (kind === "flat") return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOfUrl(e.loc))));
|
|
137
194
|
|
|
138
195
|
if (kind === "child") {
|
|
139
196
|
const g = (group ?? "").replace(/\.xml$/i, "");
|
|
197
|
+
if (excluded.has(g)) return EMPTY; // раздел выключен → пустая (валидная) карта
|
|
140
198
|
return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
|
|
141
199
|
}
|
|
142
200
|
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
}
|
|
145
209
|
return indexXml(origin, groups);
|
|
146
210
|
}
|