feat(seo): discoverability + Schema.org + IndexNow + Trivy (#1)
All checks were successful
deploy / deploy (push) Successful in 3m7s
All checks were successful
deploy / deploy (push) Successful in 3m7s
This commit was merged in pull request #1.
This commit is contained in:
@@ -42,9 +42,35 @@ jobs:
|
|||||||
|
|
||||||
cd "$DEPLOY_PATH"
|
cd "$DEPLOY_PATH"
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
|
# Trivy scan локально собранного образа (HIGH+CRITICAL, не блокирует).
|
||||||
|
# ghcr.io вместо docker.io — обход rate limit Docker Hub.
|
||||||
|
echo "=== Trivy scan: anotherreflections-ru-v2:latest ==="
|
||||||
|
docker run --rm \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v /tmp/trivy-cache:/root/.cache/ \
|
||||||
|
ghcr.io/aquasecurity/trivy:latest image \
|
||||||
|
--severity HIGH,CRITICAL \
|
||||||
|
--no-progress \
|
||||||
|
--exit-code 0 \
|
||||||
|
--timeout 5m \
|
||||||
|
anotherreflections-ru-v2:latest || true
|
||||||
|
echo "=== Trivy scan done ==="
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
sleep 5
|
sleep 5
|
||||||
docker compose ps
|
docker compose ps
|
||||||
curl -fsS -o /dev/null -w "HEALTH HTTP %{http_code}\n" "$HEALTH_URL"
|
curl -fsS -o /dev/null -w "HEALTH HTTP %{http_code}\n" "$HEALTH_URL"
|
||||||
docker image prune -af --filter "until=168h" >/dev/null 2>&1 || true
|
docker image prune -af --filter "until=168h" >/dev/null 2>&1 || true
|
||||||
REMOTE
|
REMOTE
|
||||||
|
|
||||||
|
- name: Checkout repo for IndexNow
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Notify IndexNow (Yandex/Bing)
|
||||||
|
run: npm run indexnow || true
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"migrate": "node scripts/migrate-wp.mjs"
|
"migrate": "node scripts/migrate-wp.mjs",
|
||||||
|
"indexnow": "node scripts/indexnow.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/rss": "^4.0.12",
|
"@astrojs/rss": "^4.0.12",
|
||||||
|
|||||||
6
public/.well-known/security.txt
Normal file
6
public/.well-known/security.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Contact: mailto:admin@anotherreflections.ru
|
||||||
|
Contact: https://anotherreflections.ru/kontakty
|
||||||
|
Expires: 2027-05-21T00:00:00.000Z
|
||||||
|
Preferred-Languages: ru, en
|
||||||
|
Canonical: https://anotherreflections.ru/.well-known/security.txt
|
||||||
|
Policy: https://anotherreflections.ru/privacy
|
||||||
1
public/15455d9f2c7b473bb04336055b792ec9.txt
Normal file
1
public/15455d9f2c7b473bb04336055b792ec9.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
15455d9f2c7b473bb04336055b792ec9
|
||||||
10
public/humans.txt
Normal file
10
public/humans.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* SITE */
|
||||||
|
Site: Иные Отражения — anotherreflections.ru
|
||||||
|
Last update: 2026-05-21
|
||||||
|
Standards: HTML5, CSS3, RSS 2.0
|
||||||
|
Components: Astro 5, nginx 1.29
|
||||||
|
Software: Docker, Gitea Actions
|
||||||
|
|
||||||
|
/* TEAM */
|
||||||
|
Maintained by: HHIVP
|
||||||
|
Contact: admin@anotherreflections.ru
|
||||||
133
scripts/indexnow.js
Normal file
133
scripts/indexnow.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// IndexNow: уведомить Yandex/Bing о новых/обновлённых URL.
|
||||||
|
// Запускается после деплоя из CI или вручную:
|
||||||
|
// node scripts/indexnow.js
|
||||||
|
//
|
||||||
|
// Источник URL — sitemap. Сначала пробуем локальный dist/sitemap*.xml
|
||||||
|
// (если запускаем сразу после `npm run build`), иначе тянем
|
||||||
|
// https://anotherreflections.ru/sitemap-index.xml через сеть.
|
||||||
|
//
|
||||||
|
// IndexNow ключ хранится в файле public/<key>.txt — он должен быть доступен
|
||||||
|
// по тому же URL что и сайт, чтобы поисковики могли подтвердить ownership.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const BASE = 'https://anotherreflections.ru';
|
||||||
|
const HOST = 'anotherreflections.ru';
|
||||||
|
const KEY = '15455d9f2c7b473bb04336055b792ec9'; // 32-char hex
|
||||||
|
|
||||||
|
// Создать key-файл если кто-то его удалил.
|
||||||
|
const keyFile = path.join(root, 'public', `${KEY}.txt`);
|
||||||
|
if (!fs.existsSync(keyFile)) {
|
||||||
|
fs.writeFileSync(keyFile, KEY, 'utf-8');
|
||||||
|
console.log(`created key file: public/${KEY}.txt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLocs(xml) {
|
||||||
|
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url) {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`);
|
||||||
|
return r.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectFromRemoteIndex(indexUrl) {
|
||||||
|
const indexXml = await fetchText(indexUrl);
|
||||||
|
const locs = extractLocs(indexXml);
|
||||||
|
// Если это sitemap-index — locs указывают на дочерние sitemap-N.xml.
|
||||||
|
// Если это обычный sitemap — locs уже URL'ы страниц.
|
||||||
|
const looksLikeIndex = /<sitemapindex/i.test(indexXml);
|
||||||
|
if (!looksLikeIndex) return locs;
|
||||||
|
const childUrls = [];
|
||||||
|
for (const loc of locs) {
|
||||||
|
try {
|
||||||
|
const childXml = await fetchText(loc);
|
||||||
|
childUrls.push(...extractLocs(childXml));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` skip ${loc}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return childUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFromLocalDist() {
|
||||||
|
const distDir = path.join(root, 'dist');
|
||||||
|
if (!fs.existsSync(distDir)) return null;
|
||||||
|
const sitemapFiles = fs
|
||||||
|
.readdirSync(distDir)
|
||||||
|
.filter((f) => /^sitemap-\d+\.xml$/.test(f));
|
||||||
|
let urls = [];
|
||||||
|
if (sitemapFiles.length > 0) {
|
||||||
|
for (const f of sitemapFiles) {
|
||||||
|
const xml = fs.readFileSync(path.join(distDir, f), 'utf-8');
|
||||||
|
urls.push(...extractLocs(xml));
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
for (const c of ['sitemap-index.xml', 'sitemap.xml']) {
|
||||||
|
const p = path.join(distDir, c);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
const xml = fs.readFileSync(p, 'utf-8');
|
||||||
|
return extractLocs(xml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let urls = collectFromLocalDist();
|
||||||
|
if (urls === null || urls.length === 0) {
|
||||||
|
console.log('no local dist/sitemap*.xml — fetching remote sitemap-index.xml');
|
||||||
|
try {
|
||||||
|
urls = await collectFromRemoteIndex(`${BASE}/sitemap-index.xml`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`failed to fetch remote sitemap: ${e.message}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уникальные URL только нашего хоста.
|
||||||
|
urls = [...new Set(urls)].filter((u) => {
|
||||||
|
try {
|
||||||
|
return new URL(u).host === HOST;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (urls.length === 0) {
|
||||||
|
console.error('no URLs found; nothing to submit');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Submitting ${urls.length} URLs to IndexNow…`);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
host: HOST,
|
||||||
|
key: KEY,
|
||||||
|
keyLocation: `${BASE}/${KEY}.txt`,
|
||||||
|
urlList: urls,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function submit(endpoint) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
console.log(` ${endpoint}: HTTP ${r.status}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` ${endpoint}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
submit('https://yandex.com/indexnow'),
|
||||||
|
submit('https://api.indexnow.org/indexnow'),
|
||||||
|
]);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL, SITE_LANG, MAIN_NAV } from '../consts';
|
import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL, SITE_LANG, SITE_FOUNDED, MAIN_NAV, SOCIAL } from '../consts';
|
||||||
import BrandMark from '../components/BrandMark.astro';
|
import BrandMark from '../components/BrandMark.astro';
|
||||||
import SocialLinks from '../components/SocialLinks.astro';
|
import SocialLinks from '../components/SocialLinks.astro';
|
||||||
import Analytics from '../components/Analytics.astro';
|
import Analytics from '../components/Analytics.astro';
|
||||||
@@ -17,6 +17,35 @@ const pageTitle = title ? `${title} — ${SITE_TITLE}` : SITE_TITLE;
|
|||||||
const pageDesc = description || SITE_DESCRIPTION;
|
const pageDesc = description || SITE_DESCRIPTION;
|
||||||
const canonical = new URL(Astro.url.pathname, SITE_URL).toString();
|
const canonical = new URL(Astro.url.pathname, SITE_URL).toString();
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
const ogImage = new URL('/og-image.png', SITE_URL).toString();
|
||||||
|
const logoUrl = new URL('/logo.svg', SITE_URL).toString();
|
||||||
|
const jsonLd = [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: SITE_TITLE,
|
||||||
|
url: SITE_URL,
|
||||||
|
inLanguage: SITE_LANG,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
potentialAction: {
|
||||||
|
'@type': 'SearchAction',
|
||||||
|
target: `${SITE_URL}/?s={query}`,
|
||||||
|
'query-input': 'required name=query',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'NewsMediaOrganization',
|
||||||
|
name: SITE_TITLE,
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: logoUrl,
|
||||||
|
image: ogImage,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
foundingDate: String(SITE_FOUNDED),
|
||||||
|
sameAs: Object.values(SOCIAL).map((s) => s.url),
|
||||||
|
},
|
||||||
|
];
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang={SITE_LANG}>
|
<html lang={SITE_LANG}>
|
||||||
@@ -46,6 +75,8 @@ const year = new Date().getFullYear();
|
|||||||
|
|
||||||
<link rel="alternate" type="application/rss+xml" title={`${SITE_TITLE} — RSS`} href="/feed.xml" />
|
<link rel="alternate" type="application/rss+xml" title={`${SITE_TITLE} — RSS`} href="/feed.xml" />
|
||||||
|
|
||||||
|
<script type="application/ld+json" is:inline set:html={JSON.stringify(jsonLd)} />
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Cormorant+Garamond:wght@500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Cormorant+Garamond:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
|||||||
Reference in New Issue
Block a user