优化SEO
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
- 留言墙:接入 Twikoo 后可在页面中留言。
|
||||
- 全站搜索:通过 Typesense 搜索并筛选时间线、照片、文章和同学公开资料。
|
||||
- PWA:支持添加到主屏幕,并离线访问站点外壳及已浏览的同源内容。
|
||||
- 字体加密:生产构建会随机混淆页面中的汉字,并生成按需裁剪的 WOFF2 字体,降低静态内容被直接复制或抓取的风险。
|
||||
- SEO:默认生产构建保留可读正文,并生成 canonical、结构化数据、`robots.txt` 与站点地图,便于搜索引擎抓取。
|
||||
- 可选字体保护:需要降低静态内容被直接复制的风险时,可单独执行受保护构建。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -34,14 +35,15 @@ npm run dev
|
||||
|
||||
```bash
|
||||
npm run dev # 启动开发服务器
|
||||
npm run build # 构建静态站点到 dist
|
||||
npm run build # 构建适合搜索引擎抓取的静态站点到 dist
|
||||
npm run build:protected # 可选:构建带汉字字体混淆的版本
|
||||
npm run preview # 预览构建结果
|
||||
npm run search:sync # 将公开内容同步到 Typesense
|
||||
```
|
||||
|
||||
`npm run build` 会在 Astro 构建完成后加密页面字体,再生成带内容版本号的 Service Worker,并预缓存页面、样式、脚本、加密字体、应用图标和首页主视觉。照片等大体积资源采用访问后缓存,第三方接口始终走网络。
|
||||
`npm run build` 会在 Astro 构建完成后生成带内容版本号的 Service Worker,并预缓存页面、样式、脚本、应用图标和首页主视觉。照片等大体积资源采用访问后缓存,第三方接口始终走网络。
|
||||
|
||||
字体加密只作用于 `dist` 中的生产页面,不修改源码,也不会影响本地开发。每次构建默认生成新的随机映射;如需可复现产物,可设置 `FONT_ENCRYPTION_SEED`。`code`、`pre`、表单属性以及脚本动态生成的内容会保留原文,以免影响复制和交互。字体加密只能提高批量采集成本,不能替代访问控制或版权保护。
|
||||
`npm run build:protected` 会额外混淆 `dist` 页面中的汉字并生成按需裁剪的 WOFF2 字体。该模式会降低搜索引擎理解正文的能力,不适合需要 SEO 的正式部署。字体混淆不修改源码,也不能替代访问控制或版权保护。
|
||||
|
||||
## Typesense 全站搜索
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { defineConfig } from "astro/config";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
site: "https://example.com",
|
||||
site: "https://www.2024612.xyz/",
|
||||
vite: {
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build && node scripts/encrypt-font.mjs && node scripts/build-pwa.mjs",
|
||||
"build": "astro build && node scripts/build-pwa.mjs",
|
||||
"build:protected": "astro build && node scripts/encrypt-font.mjs && node scripts/build-pwa.mjs",
|
||||
"preview": "astro preview",
|
||||
"search:key": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/create-typesense-search-key.mjs",
|
||||
"search:sync": "node --env-file-if-exists=.env --env-file-if-exists=.env.local --import tsx scripts/sync-typesense.ts"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "2024届612班纪念网站",
|
||||
"name": "高平一中612班纪念网站",
|
||||
"short_name": "612班",
|
||||
"description": "保存2024届612班共同走过的校园时光,也记录毕业后的我们。",
|
||||
"description": "保存高平一中2024届612班共同走过的校园时光,也记录毕业后的我们。",
|
||||
"lang": "zh-CN",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
import { site } from "../data/site";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
title = site.name,
|
||||
description = site.description,
|
||||
image = site.heroImage,
|
||||
noindex = false
|
||||
} = Astro.props;
|
||||
|
||||
const documentTitle = title === site.name || title.includes(site.name)
|
||||
? title
|
||||
: `${title} · ${site.name}`;
|
||||
const baseUrl = Astro.site ?? new URL(site.url);
|
||||
const canonicalUrl = new URL(Astro.url.pathname, baseUrl);
|
||||
const imageUrl = new URL(image, baseUrl);
|
||||
const robots = noindex
|
||||
? "noindex, nofollow"
|
||||
: "index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1";
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${new URL("/", baseUrl)}#website`,
|
||||
url: new URL("/", baseUrl).toString(),
|
||||
name: site.name,
|
||||
alternateName: [site.shortName, `${site.cohort}${site.shortName}`],
|
||||
description: site.description,
|
||||
inLanguage: "zh-CN"
|
||||
},
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"@id": `${canonicalUrl}#webpage`,
|
||||
url: canonicalUrl.toString(),
|
||||
name: documentTitle,
|
||||
description,
|
||||
isPartOf: { "@id": `${new URL("/", baseUrl)}#website` },
|
||||
inLanguage: "zh-CN"
|
||||
}
|
||||
]
|
||||
};
|
||||
---
|
||||
|
||||
<title>{documentTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="robots" content={robots} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
<meta property="og:site_name" content={site.name} />
|
||||
<meta property="og:title" content={documentTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
<meta property="og:image:alt" content={`${site.name}校园记忆`} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={documentTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(structuredData)} />
|
||||
@@ -907,7 +907,7 @@
|
||||
"displayDate": "2025.05.28",
|
||||
"sourceUrl": "https://mp.weixin.qq.com/s/BzwaFT3_-lTwkz1keEexZg",
|
||||
"cover": "/assets/article-covers/cover-325470909f3b.webp",
|
||||
"excerpt": "网站群于今日起上线试运营。 共三个站点: 主站点:https://class612.bond",
|
||||
"excerpt": "网站群于今日起上线试运营。 共三个站点: 主站点:https://www.2024612.xyz/",
|
||||
"category": "校园通知",
|
||||
"fileName": "网站群上线通知.md",
|
||||
"coverSourceUrl": "https://mmbiz.qpic.cn/sz_mmbiz_jpg/RKNa5pic8piampaibDJNlrCiawqhia3LzibyNa3MNNlEAUAOqz6Fxyurqr4ic3YokNjqMPazGibr1vl2lQX67PLwVWWFlg/640?wx_fmt=jpeg"
|
||||
|
||||
+8
-2
@@ -1,11 +1,17 @@
|
||||
export const site = {
|
||||
className: "2024届612班",
|
||||
name: "高平一中612班",
|
||||
shortName: "612班",
|
||||
className: "高平一中612班",
|
||||
cohort: "2024届",
|
||||
url: "https://www.2024612.xyz/",
|
||||
title: "毕业后,我们依然在场",
|
||||
subtitle:
|
||||
"把散落在相册、聊天记录和心里的瞬间放回同一个地方。这里先留给高中的三年,也留给一年后的我们。",
|
||||
description:
|
||||
"高平一中2024届612班纪念网站,收录三年时间线、班级照片、青春刊物和同学近况,保存我们共同走过的高中时光。",
|
||||
anniversary: "2024.06 - 2026.06 · 毕业周年纪念",
|
||||
heroImage: "/assets/campus-hero.png",
|
||||
footer: "2024届612班纪念网站"
|
||||
footer: "高平一中612班纪念网站"
|
||||
};
|
||||
|
||||
export const contactLinks = [
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
import packageJson from "../../package.json";
|
||||
import SeoHead from "../components/SeoHead.astro";
|
||||
import { contactLinks, navItems, site } from "../data/site";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
const { title = site.title } = Astro.props;
|
||||
const {
|
||||
title = site.name,
|
||||
description = site.description,
|
||||
image = site.heroImage,
|
||||
noindex = false
|
||||
} = Astro.props;
|
||||
const buildTime = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
@@ -26,14 +35,13 @@ const buildTime = new Intl.DateTimeFormat("zh-CN", {
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta name="description" content={site.subtitle} />
|
||||
<SeoHead {title} {description} {image} {noindex} />
|
||||
<meta name="theme-color" content="#156b70" />
|
||||
<meta name="application-name" content={site.className} />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content={site.className} />
|
||||
<title>{title}</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/pwa-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
---
|
||||
import "../styles/anniversary.css";
|
||||
import SeoHead from "../components/SeoHead.astro";
|
||||
import { site } from "../data/site";
|
||||
|
||||
const memoryCards = [
|
||||
{
|
||||
@@ -36,6 +38,8 @@ const gallery = [
|
||||
const backgroundMusic = "/assets/anniversary/background-music.mp3";
|
||||
const anniversaryVideo = "/assets/anniversary/anniversary-video.mp4";
|
||||
const videoPoster = "/assets/campus-hero.png";
|
||||
const anniversaryDescription =
|
||||
"高平一中2024届612班毕业周年专题,重温三年里的时间切片、班级影像和毕业后的共同回忆。";
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -43,11 +47,12 @@ const videoPoster = "/assets/campus-hero.png";
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>2024届12班周年专题</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="2024届12班周年专题页,收下那些毕业之后仍然清晰的瞬间。"
|
||||
<SeoHead
|
||||
title="毕业周年专题"
|
||||
description={anniversaryDescription}
|
||||
image={videoPoster}
|
||||
/>
|
||||
<meta name="theme-color" content="#2d433c" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="anniversary-page">
|
||||
@@ -55,7 +60,7 @@ const videoPoster = "/assets/campus-hero.png";
|
||||
<img class="hero-media" src="/assets/campus-hero.png" alt="校园记忆" />
|
||||
<div class="hero-shade" aria-hidden="true"></div>
|
||||
<nav class="topbar" aria-label="周年专题导航">
|
||||
<a class="home-link" href="/">2024届12班</a>
|
||||
<a class="home-link" href="/">{site.name}</a>
|
||||
<div class="topbar-links">
|
||||
<a href="#memory">时间切片</a>
|
||||
<a href="#gallery">影像回放</a>
|
||||
@@ -151,7 +156,7 @@ const videoPoster = "/assets/campus-hero.png";
|
||||
<h2 id="letter-title">写给毕业后的我们</h2>
|
||||
<p>
|
||||
后来的我们会遇见新的城市、新的课程、新的工作和新的自己。可只要有人再次提起那间教室、那次考试、那张合照,
|
||||
2024届12班就会短暂地重新集合。
|
||||
高平一中612班就会短暂地重新集合。
|
||||
</p>
|
||||
<p>
|
||||
愿每一次回到这里,都不是为了停在过去,而是确认:那些一起认真生活过的日子,已经成为继续往前走的底气。
|
||||
|
||||
@@ -7,7 +7,10 @@ const categoryCounts = Object.fromEntries(
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout title="青春刊物 · 2024届612班">
|
||||
<BaseLayout
|
||||
title="青春刊物"
|
||||
description="收录高平一中612班的班级日志、校园时刻、毕业典礼与节日来信,按时间重新翻阅共同的高中记忆。"
|
||||
>
|
||||
<main class="page-main publication-page">
|
||||
<section class="page-hero publication-hero">
|
||||
<div class="section-inner">
|
||||
|
||||
@@ -60,7 +60,10 @@ const answerCardQuestions = [
|
||||
let questionNumber = 0;
|
||||
---
|
||||
|
||||
<BaseLayout title={`${examIntro.title} · ${site.className}`}>
|
||||
<BaseLayout
|
||||
title={`${examIntro.title} · ${site.className}`}
|
||||
description={examIntro.description}
|
||||
>
|
||||
<main class="page-main exam-page">
|
||||
<section class="page-hero">
|
||||
<div class="section-inner">
|
||||
@@ -141,7 +144,7 @@ let questionNumber = 0;
|
||||
{
|
||||
fillQuestion?.mode === "unordered-names" ? (
|
||||
<div class="name-fill-grid" aria-label="第 14 题姓名默写作答">
|
||||
{Array.from({ length: fillQuestion.blankCount }, (_, blankIndex) => (
|
||||
{Array.from({ length: fillQuestion.blankCount ?? 1 }, (_, blankIndex) => (
|
||||
<label>
|
||||
<span>{blankIndex + 1}</span>
|
||||
<input
|
||||
|
||||
@@ -3,7 +3,11 @@ import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||
import { site } from "../../data/site";
|
||||
---
|
||||
|
||||
<BaseLayout title={`成绩分析 · ${site.className}`}>
|
||||
<BaseLayout
|
||||
title={`成绩分析 · ${site.className}`}
|
||||
description="高平一中612班综合素质检测的本地成绩分析页。"
|
||||
noindex
|
||||
>
|
||||
<main class="page-main">
|
||||
<section class="page-hero result-hero">
|
||||
<div class="section-inner">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { galleryIntro, photoTopics } from "../data/photos";
|
||||
import { site } from "../data/site";
|
||||
---
|
||||
|
||||
<BaseLayout title={`${galleryIntro.title} · ${site.className}`}>
|
||||
<BaseLayout title={`${galleryIntro.title} · ${site.className}`} description={galleryIntro.text}>
|
||||
<main class="page-main">
|
||||
<section class="page-hero">
|
||||
<div class="section-inner">
|
||||
|
||||
@@ -13,7 +13,11 @@ export function getStaticPaths() {
|
||||
const { topic } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={`${topic.title} · ${galleryIntro.title} · ${site.className}`}>
|
||||
<BaseLayout
|
||||
title={`${topic.title} · ${galleryIntro.title} · ${site.className}`}
|
||||
description={topic.text}
|
||||
image={topic.cover || site.heroImage}
|
||||
>
|
||||
<main class="page-main">
|
||||
<section class="page-hero">
|
||||
<div class="section-inner">
|
||||
|
||||
@@ -14,7 +14,7 @@ const previewPhotoTopics = photoTopics.slice(0, 4);
|
||||
const previewPeople = people.slice(0, 6);
|
||||
---
|
||||
|
||||
<BaseLayout title={site.title}>
|
||||
<BaseLayout title={site.name} description={site.description}>
|
||||
<section
|
||||
id="top"
|
||||
class="hero"
|
||||
|
||||
+4
-1
@@ -72,7 +72,10 @@ const displayCities = layoutCityMarkers();
|
||||
const isDenseMap = displayCities.length > 12;
|
||||
---
|
||||
|
||||
<BaseLayout title={`班级地图 · ${site.className}`}>
|
||||
<BaseLayout
|
||||
title={`班级地图 · ${site.className}`}
|
||||
description="查看高平一中612班同学毕业后的公开城市分布,记录我们从同一间教室走向不同地方的轨迹。"
|
||||
>
|
||||
<main class="page-main map-page">
|
||||
<section class="page-hero map-hero">
|
||||
<div class="section-inner">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { site } from "../data/site";
|
||||
import twikooThemeUrl from "../styles/twikoo2.css?url";
|
||||
---
|
||||
|
||||
<BaseLayout title={`${messagesIntro.title} · ${site.className}`}>
|
||||
<BaseLayout title={`${messagesIntro.title} · ${site.className}`} description={messagesIntro.text}>
|
||||
<main class="page-main">
|
||||
<section class="page-hero">
|
||||
<div class="section-inner">
|
||||
|
||||
@@ -3,7 +3,11 @@ import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import { site } from "../data/site";
|
||||
---
|
||||
|
||||
<BaseLayout title={`暂时离线 · ${site.className}`}>
|
||||
<BaseLayout
|
||||
title={`暂时离线 · ${site.className}`}
|
||||
description="高平一中612班纪念网站的离线提示页。"
|
||||
noindex
|
||||
>
|
||||
<main class="offline-page">
|
||||
<section class="offline-card" aria-labelledby="offline-title">
|
||||
<p class="offline-mark" aria-hidden="true">612</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { people, peopleIntro } from "../data/people";
|
||||
import { site } from "../data/site";
|
||||
---
|
||||
|
||||
<BaseLayout title={`${peopleIntro.title} · ${site.className}`}>
|
||||
<BaseLayout title={`${peopleIntro.title} · ${site.className}`} description={peopleIntro.text}>
|
||||
<main class="page-main">
|
||||
<section class="page-hero">
|
||||
<div class="section-inner">
|
||||
|
||||
@@ -13,7 +13,11 @@ export function getStaticPaths() {
|
||||
const { person } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={`${person.name} · ${peopleIntro.title} · ${site.className}`}>
|
||||
<BaseLayout
|
||||
title={`${person.name} · ${peopleIntro.title} · ${site.className}`}
|
||||
description={`${person.name}的近况:${person.text}`}
|
||||
image={person.photo || site.heroImage}
|
||||
>
|
||||
<main class="page-main">
|
||||
<section class="page-hero person-hero">
|
||||
<div class="section-inner person-hero-inner">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { site as siteData } from "../data/site";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const baseUrl = site ?? new URL(siteData.url);
|
||||
const sitemapUrl = new URL("/sitemap.xml", baseUrl);
|
||||
const body = [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
`Sitemap: ${sitemapUrl}`,
|
||||
""
|
||||
].join("\n");
|
||||
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" }
|
||||
});
|
||||
};
|
||||
@@ -13,7 +13,11 @@ const typesenseConfig = {
|
||||
const isConfigured = Boolean(typesenseConfig.host && typesenseConfig.apiKey);
|
||||
---
|
||||
|
||||
<BaseLayout title={`全站搜索 · ${site.className}`}>
|
||||
<BaseLayout
|
||||
title={`全站搜索 · ${site.className}`}
|
||||
description="搜索高平一中612班的时间线、照片、青春刊物和同学近况。"
|
||||
noindex
|
||||
>
|
||||
<main>
|
||||
<section class="page-hero search-hero">
|
||||
<div class="section-heading">
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { people } from "../data/people";
|
||||
import { photoTopics } from "../data/photos";
|
||||
import { site as siteData } from "../data/site";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
const staticRoutes = [
|
||||
"/",
|
||||
"/timeline/",
|
||||
"/gallery/",
|
||||
"/articles/",
|
||||
"/people/",
|
||||
"/map/",
|
||||
"/exam/",
|
||||
"/messages/",
|
||||
"/anniversary/"
|
||||
];
|
||||
|
||||
const escapeXml = (value: string) =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const baseUrl = site ?? new URL(siteData.url);
|
||||
const routes = [
|
||||
...staticRoutes,
|
||||
...photoTopics.map((topic) => `/gallery/${topic.slug}/`),
|
||||
...people.map((person) => `/people/${person.slug}/`)
|
||||
];
|
||||
const urls = [...new Set(routes)]
|
||||
.map((route) => ` <url><loc>${escapeXml(new URL(route, baseUrl).toString())}</loc></url>`)
|
||||
.join("\n");
|
||||
const body = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
urls,
|
||||
"</urlset>",
|
||||
""
|
||||
].join("\n");
|
||||
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "application/xml; charset=utf-8" }
|
||||
});
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { site } from "../data/site";
|
||||
import { timeline, timelineIntro } from "../data/timeline";
|
||||
---
|
||||
|
||||
<BaseLayout title={`${timelineIntro.title} · ${site.className}`}>
|
||||
<BaseLayout title={`${timelineIntro.title} · ${site.className}`} description={timelineIntro.text}>
|
||||
<main class="page-main">
|
||||
<section class="page-hero">
|
||||
<div class="section-inner">
|
||||
|
||||
Reference in New Issue
Block a user