在 使用 AWS Kiro 與 S3 + CloudFront 打造現代化靜態網站部署流程(一) 中,我們完成了 Hexo 部落格的基礎架設、AWS S3 配置以及 CloudFront CDN 部署。本篇將深入解析專案中四個核心程式碼檔案的設計理念與實作細節:
Github 連結: Xian Yu Hexo_parser
tools/爬取ithome文章.js - 單篇文章爬蟲tools/批量爬取系列文章.js - 批量系列爬蟲themes/fast-theme/source/js/gsap-animations.js - GSAP 動畫系統themes/fast-theme/source/css/style.css - Cyberpunk 主題樣式
tools/爬取ithome文章.js)這個工具的目的是:
node tools/爬取ithome文章.js https://ithelp.ithome.com.tw/articles/10234567
const https = require('https'); // Node.js 內建 HTTPS 模組,用於發送網路請求
const fs = require('fs'); // 檔案系統模組,用於讀寫檔案
const path = require('path'); // 路徑處理模組,用於跨平台路徑操作
為什麼使用原生模組?
fetchArticle(url) - 網頁抓取function fetchArticle(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk); // 串流接收資料
res.on('end', () => resolve(data)); // 完成時回傳
}).on('error', reject); // 錯誤處理
});
}
設計要點:
parseArticle(html) - HTML 解析這是整個爬蟲的核心,負責從 HTML 中提取結構化資料:
function parseArticle(html) {
// 1. 提取標題 - 優先從 <title> 標籤抓取
let title = '未知標題';
const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/s);
if (titleMatch) {
title = titleMatch[1]
.replace(/<[^>]*>/g, '') // 移除 HTML 標籤
.replace(/\s*-\s*iT\s*邦幫忙.*$/i, '') // 移除網站名稱
.replace(/\s*\|\s*iThome.*$/i, '')
.trim();
}
標題提取策略:
<title> 標籤(最可靠).qa-list__title class(iThome 特定選擇器) // 2. 提取日期 - 多重來源嘗試
let date = new Date().toISOString().split('T')[0]; // 預設今天
const dateMatch = html.match(/<time[^>]*datetime="([^"]*)"[^>]*>/);
if (dateMatch) {
date = dateMatch[1].split('T')[0];
} else {
// 備選:從 meta 標籤抓取
const metaDateMatch = html.match(
/<meta[^>]*property="article:published_time"[^>]*content="([^"]*)"[^>]*>/
);
if (metaDateMatch) {
date = metaDateMatch[1].split('T')[0];
}
}
日期提取策略:
<time datetime> 標籤(語義化 HTML)article:published_time meta 標籤這是最複雜的部分,使用正則表達式鏈式轉換:
content = content
// 程式碼區塊:<pre><code> → ```code```
.replace(/<pre><code[^>]*>(.*?)<\/code><\/pre>/gs, (match, code) => {
return '\n```\n' + code.replace(/<[^>]*>/g, '').trim() + '\n```\n';
})
// 標題轉換:<h1>~<h4> → #~####
.replace(/<h1[^>]*>(.*?)<\/h1>/g, '\n# $1\n')
.replace(/<h2[^>]*>(.*?)<\/h2>/g, '\n## $1\n')
.replace(/<h3[^>]*>(.*?)<\/h3>/g, '\n### $1\n')
.replace(/<h4[^>]*>(.*?)<\/h4>/g, '\n#### $1\n')
轉換規則對照表:
| HTML 元素 | Markdown 語法 | 說明 |
|---|---|---|
<pre><code> |
```code``` |
程式碼區塊 |
<h1>~<h4> |
#~#### |
標題層級 |
<ul><li> |
- item |
無序列表 |
<ol><li> |
1. item |
有序列表 |
<a href="url"> |
[text](url) |
超連結 |
<img src="url"> |
 |
圖片 |
<strong> |
**bold** |
粗體 |
<em> |
*italic* |
斜體 |
// 列表轉換 - 需要特殊處理計數器
.replace(/<ul[^>]*>(.*?)<\/ul>/gs, (match, list) => {
return list.replace(/<li[^>]*>(.*?)<\/li>/g, '- $1\n');
})
.replace(/<ol[^>]*>(.*?)<\/ol>/gs, (match, list) => {
let counter = 1;
return list.replace(/<li[^>]*>(.*?)<\/li>/g, (m, item) =>
`${counter++}. ${item}\n`
);
})
// 連結與圖片
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/g, '[$2]($1)')
.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/g, '')
.replace(/<img[^>]*src="([^"]*)"[^>]*>/g, '')
// 文字格式
.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**')
.replace(/<em[^>]*>(.*?)<\/em>/g, '*$1*')
// 段落與換行
.replace(/<p[^>]*>(.*?)<\/p>/gs, '$1\n\n')
.replace(/<br\s*\/?>/g, '\n')
// 清理殘留 HTML
.replace(/<[^>]*>/g, '')
// HTML 實體解碼
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/ /g, ' ')
// 清理多餘空行
.replace(/\n{3,}/g, '\n\n')
.trim();
正則表達式技巧說明:
/gs 修飾符:g 全域匹配,s 讓 . 匹配換行符[^>]*:匹配任意非 > 字元,用於跳過 HTML 屬性(.*?):非貪婪匹配,避免跨標籤匹配const tagsMatch = html.match(/<a[^>]*class="[^"]*tag[^"]*"[^>]*>(.*?)<\/a>/g);
let tags = [];
if (tagsMatch) {
tags = tagsMatch
.map(tag => tag.replace(/<[^>]*>/g, '').trim())
.filter(tag => tag && tag.length > 0)
// 去重
.filter((tag, index, self) => self.indexOf(tag) === index)
// 過濾通用標籤
.filter(tag => !tag.match(/^\d+(th|st|nd|rd)鐵人賽$/))
.filter(tag => !tag.match(/^20\d{2}鐵人賽$/))
.filter(tag => tag !== '鐵人賽')
// 限制數量
.slice(0, 5);
}
過濾邏輯:
slugify(text)function slugify(text) {
return text
.toLowerCase()
.replace(/[^\w\u4e00-\u9fa5]+/g, '-') // 保留英數字與中文
.replace(/^-+|-+$/g, ''); // 移除首尾連字符
}
設計考量:
\u4e00-\u9fa5 是 CJK 統一漢字範圍)async function main() {
const url = process.argv[2]; // 從命令列取得 URL
// 參數驗證
if (!url || !url.includes('ithelp.ithome.com.tw')) {
console.log('❌ 請提供有效的 iThome 文章網址');
process.exit(1);
}
// 執行爬取
const html = await fetchArticle(url);
const { title, date, content, tags } = parseArticle(html);
// 生成 Front-matter
const frontMatter = `---
title: ${title}
date: ${date}
tags: [${tags.join(', ')}]
categories: 技術文章
source: ${url}
---
${content}
`;
// 寫入檔案
const filename = `${date}-${slugify(title)}.md`;
const filepath = path.join(process.cwd(), 'source', '_posts', filename);
fs.writeFileSync(filepath, frontMatter, 'utf8');
}
tools/批量爬取系列文章.js)node tools/批量爬取系列文章.js
const series_urls = [
// C++ 基礎教學系列(3 頁)
"https://ithelp.ithome.com.tw/users/20151593/ironman/5369",
"https://ithelp.ithome.com.tw/users/20151593/ironman/5369?page=2",
"https://ithelp.ithome.com.tw/users/20151593/ironman/5369?page=3",
// Flutter 30天系列(3 頁)
"https://ithelp.ithome.com.tw/users/20151593/ironman/5953",
// ... 更多頁面
];
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 使用方式
await sleep(2000); // 等待 2 秒
await sleep(3000); // 等待 3 秒
function fetchPage(url) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
// 模擬 Chrome 瀏覽器
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' +
'AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9',
'Accept-Language': 'zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1'
}
};
為什麼需要這些 Headers?
| Header | 用途 |
|---|---|
User-Agent |
告訴伺服器我們是「正常瀏覽器」而非爬蟲 |
Accept |
表明我們接受的內容類型 |
Accept-Language |
偏好語言,影響回傳內容 |
Accept-Encoding |
支援壓縮,減少傳輸量 |
https.get(options, (res) => {
let data = '';
const encoding = res.headers['content-encoding'];
if (encoding && encoding.includes('gzip')) {
const zlib = require('zlib');
const gunzip = zlib.createGunzip();
res.pipe(gunzip);
gunzip.on('data', (chunk) => data += chunk.toString());
gunzip.on('end', () => resolve(data));
gunzip.on('error', reject);
} else {
res.on('data', (chunk) => data += chunk);
res.on('end', () => resolve(data));
}
});
為什麼要處理 Gzip?
zlib 模組處理function extractArticleLinks(html) {
const links = [];
// 方法 1: 匹配 iThome 特定的文章連結 class
const titleLinkRegex = /<a[^>]*href="\s*(https:\/\/ithelp\.ithome\.com\.tw\/articles\/\d+)\s*"[^>]*class="qa-list__title-link"/gs;
let match;
while ((match = titleLinkRegex.exec(html)) !== null) {
const articleUrl = match[1].trim();
if (!links.includes(articleUrl)) {
links.push(articleUrl);
}
}
// 方法 2: 備用方案 - 匹配任何 /articles/ 連結
if (links.length === 0) {
const articleRegex = /href="\s*((?:https:\/\/ithelp\.ithome\.com\.tw)?\/articles\/\d+)\s*"/gs;
while ((match = articleRegex.exec(html)) !== null) {
let url = match[1].trim();
if (!url.startsWith('http')) {
url = 'https://ithelp.ithome.com.tw' + url;
}
if (!links.includes(url)) {
links.push(url);
}
}
}
return links;
}
雙重提取策略:
.qa-list__title-link class 選擇器/articles/ 路徑(備用方案)function saveArticle(articleData, url) {
const { title, date, content, tags } = articleData;
// 清理標題中的特殊字符(避免 YAML 解析錯誤)
const cleanTitle = title
.replace(/:/g, ':') // 英文冒號 → 中文冒號
.replace(/"/g, '') // 移除雙引號
.replace(/'/g, '') // 移除單引號
.trim();
const filename = `${date}-${slugify(title)}.md`;
const filepath = path.join(process.cwd(), 'source', '_posts', filename);
// 檢查檔案是否已存在 - 實現斷點續傳
if (fs.existsSync(filepath)) {
console.log(` ⚠️ 文章已存在,跳過: ${filename}`);
return false;
}
// 清理內容中的問題字元
const cleanContent = content
.replace(/(\d+)~(\d+)/g, '$1\\~$2') // 轉義波浪號,避免刪除線
.replace(/\n{4,}/g, '\n\n\n'); // 限制最多 3 個連續空行
fs.writeFileSync(filepath, frontMatter, 'utf8');
return true;
}
YAML Front-matter 安全處理:
: 在 YAML 中有特殊意義,需轉換~ 在 Markdown 中會變成刪除線async function main() {
console.log('🚀 開始批量爬取 iThome 系列文章\n');
let allArticleLinks = [];
// 步驟 1: 從所有系列頁面提取文章連結
for (let i = 0; i < series_urls.length; i++) {
const url = series_urls[i];
console.log(`[${i + 1}/${series_urls.length}] 正在處理: ${url}`);
try {
const html = await fetchPage(url);
const links = extractArticleLinks(html);
console.log(` ✅ 找到 ${links.length} 篇文章`);
allArticleLinks.push(...links);
await sleep(2000); // 防封鎖延遲
} catch (error) {
console.log(` ❌ 失敗: ${error.message}`);
}
}
// 去除重複連結
allArticleLinks = [...new Set(allArticleLinks)];
// 步驟 2: 逐篇爬取文章內容
let successCount = 0, skipCount = 0, failCount = 0;
for (let i = 0; i < allArticleLinks.length; i++) {
const url = allArticleLinks[i];
try {
const html = await fetchPage(url);
const articleData = parseArticle(html);
const saved = saveArticle(articleData, url);
if (saved) successCount++;
else skipCount++;
await sleep(3000); // 較長延遲,避免被封鎖
} catch (error) {
failCount++;
}
}
// 輸出統計
console.log(`✅ 成功: ${successCount} 篇`);
console.log(`⚠️ 跳過: ${skipCount} 篇(已存在)`);
console.log(`❌ 失敗: ${failCount} 篇`);
}
執行流程圖:
┌─────────────────────────────────────────────────────────┐
│ 批量爬取流程 │
├─────────────────────────────────────────────────────────┤
│ 1. 遍歷系列頁面 URL │
│ ↓ │
│ 2. 提取每頁的文章連結 (extractArticleLinks) │
│ ↓ │
│ 3. 去重合併所有連結 │
│ ↓ │
│ 4. 逐篇爬取文章 (fetchPage → parseArticle) │
│ ↓ │
│ 5. 檢查是否已存在 → 跳過或保存 │
│ ↓ │
│ 6. 輸出統計結果 │
└─────────────────────────────────────────────────────────┘
themes/fast-theme/source/js/gsap-animations.js)GSAP (GreenSock Animation Platform) 是業界標準的 JavaScript 動畫庫,特點:
// 註冊 ScrollTrigger 插件
gsap.registerPlugin(ScrollTrigger);
// 統一配置常數
const CONFIG = {
duration: {
fast: 0.3, // 快速動畫(hover 效果)
normal: 0.6, // 標準動畫
slow: 1 // 慢速動畫(頁面載入)
},
ease: {
smooth: 'power3.out', // 平滑減速
bounce: 'back.out(1.7)', // 彈跳效果
elastic: 'elastic.out(1, 0.5)' // 彈性效果
}
};
緩動函數說明:
| 緩動函數 | 效果 | 適用場景 |
|---|---|---|
power3.out |
快速開始,緩慢結束 | 一般動畫 |
back.out(1.7) |
超出目標後回彈 | 標題進場 |
elastic.out(1, 0.5) |
彈簧效果 | 按鈕互動 |
document.addEventListener('DOMContentLoaded', () => {
// 檢查使用者是否偏好減少動畫
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// 直接顯示所有內容,不播放動畫
document.querySelectorAll('.post').forEach(p => p.style.opacity = 1);
return; // 提前結束,不初始化動畫
}
// 正常初始化動畫
initPageTransition();
initHeaderAnimation();
initPostCardsAnimation();
// ...
});
為什麼這很重要?
function initPageTransition() {
// 動態創建載入遮罩
const loader = document.createElement('div');
loader.className = 'page-loader';
loader.innerHTML = `
<div class="loader-content">
<div class="loader-spinner"></div>
</div>
`;
loader.style.cssText = `
position: fixed;
inset: 0;
background: linear-gradient(135deg, #6366f1 0%, #06b6d4 100%);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
`;
document.body.appendChild(loader);
// 頁面載入完成後,遮罩向上滑出
window.addEventListener('load', () => {
gsap.to(loader, {
duration: 0.8,
yPercent: -100, // 向上移動 100%
ease: 'power4.inOut', // 對稱緩動
onComplete: () => loader.remove() // 動畫結束後移除 DOM
});
});
}
動畫時間軸:
頁面開始載入 → 顯示漸層遮罩 → 資源載入完成 → 遮罩向上滑出 → 移除遮罩
function initHeaderAnimation() {
const header = document.querySelector('.site-header');
const title = document.querySelector('.site-title');
const navLinks = document.querySelectorAll('.site-nav a');
if (!header) return;
// 標題彈跳進場
gsap.from(title, {
duration: CONFIG.duration.slow,
y: -50, // 從上方 50px 進入
opacity: 0,
ease: CONFIG.ease.bounce,
delay: 0.5 // 等待頁面過渡完成
});
// 導航連結依序出現(stagger 效果)
gsap.from(navLinks, {
duration: CONFIG.duration.normal,
y: -30,
opacity: 0,
stagger: 0.1, // 每個元素間隔 0.1 秒
ease: CONFIG.ease.smooth,
delay: 0.7
});
// 滾動時 Header 隱藏/顯示
let lastScroll = 0;
ScrollTrigger.create({
start: 'top top',
end: 99999, // 持續監聽
onUpdate: (self) => {
const scroll = self.scroll();
// 滾動超過 50px 時添加 scrolled class
if (scroll > 50) {
header.classList.add('scrolled');
} else {
header.classList.remove('scrolled');
}
// 向下滾動時隱藏 Header,向上滾動時顯示
if (scroll > lastScroll && scroll > 200) {
gsap.to(header, { y: -100, duration: 0.3 });
} else {
gsap.to(header, { y: 0, duration: 0.3 });
}
lastScroll = scroll;
}
});
}
Stagger 效果說明:
時間軸:
0.7s → 第 1 個導航連結開始動畫
0.8s → 第 2 個導航連結開始動畫
0.9s → 第 3 個導航連結開始動畫
...
function initPostCardsAnimation() {
const posts = document.querySelectorAll('.post');
posts.forEach((post, index) => {
// 設置初始狀態(隱藏)
gsap.set(post, {
opacity: 0,
y: 60, // 向下偏移
scale: 0.95, // 略微縮小
rotateX: 10 // 3D 旋轉
});
// 滾動觸發動畫
ScrollTrigger.create({
trigger: post,
start: 'top 85%', // 當卡片頂部進入視窗 85% 位置時觸發
onEnter: () => {
gsap.to(post, {
duration: 0.8,
opacity: 1,
y: 0,
scale: 1,
rotateX: 0,
ease: CONFIG.ease.smooth,
delay: index % 3 * 0.1 // 錯開動畫,避免同時觸發
});
}
});
ScrollTrigger 觸發點說明:
┌─────────────────────────────────────┐
│ 瀏覽器視窗 │
│ │
│ ─────────────────────── 0%(頂部) │
│ │
│ │
│ │
│ ─────────────────────── 85% │ ← 觸發點
│ │
│ ─────────────────────── 100%(底部)│
└─────────────────────────────────────┘
// 懸停效果
post.addEventListener('mouseenter', () => {
gsap.to(post, {
duration: CONFIG.duration.fast,
y: -8, // 向上浮起
scale: 1.02, // 略微放大
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.15)',
ease: CONFIG.ease.smooth
});
// 標題顏色變化
const title = post.querySelector('.post-title a');
if (title) {
gsap.to(title, {
duration: CONFIG.duration.fast,
color: '#6366f1' // 變為主題色
});
}
});
post.addEventListener('mouseleave', () => {
gsap.to(post, {
duration: CONFIG.duration.fast,
y: 0,
scale: 1,
boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
ease: CONFIG.ease.smooth
});
const title = post.querySelector('.post-title a');
if (title) {
gsap.to(title, {
duration: CONFIG.duration.fast,
color: '#1e293b' // 恢復原色
});
}
});
});
}
function initMagneticButtons() {
const buttons = document.querySelectorAll('.read-more, .pagination a');
buttons.forEach(btn => {
btn.addEventListener('mousemove', (e) => {
const rect = btn.getBoundingClientRect();
// 計算滑鼠相對於按鈕中心的偏移
const x = e.clientX - rect.left - rect.width / 2;
const y = e.clientY - rect.top - rect.height / 2;
// 按鈕跟隨滑鼠移動(30% 的偏移量)
gsap.to(btn, {
duration: 0.3,
x: x * 0.3,
y: y * 0.3,
ease: 'power2.out'
});
});
btn.addEventListener('mouseleave', () => {
// 滑鼠離開時,彈性回到原位
gsap.to(btn, {
duration: 0.5,
x: 0,
y: 0,
ease: 'elastic.out(1, 0.5)' // 彈性效果
});
});
});
}
磁性效果原理:
滑鼠位置 (clientX, clientY)
↓
計算相對於按鈕中心的偏移 (x, y)
↓
按鈕移動偏移量的 30% → 產生「被吸引」的感覺
↓
滑鼠離開 → 彈性回彈到原位
function initParallaxBackground() {
// 動態創建背景裝飾元素
const bgDecor = document.createElement('div');
bgDecor.className = 'bg-decoration';
bgDecor.innerHTML = `
<div class="bg-circle bg-circle-1"></div>
<div class="bg-circle bg-circle-2"></div>
<div class="bg-circle bg-circle-3"></div>
`;
document.body.appendChild(bgDecor);
// 視差滾動 - 不同圓形以不同速度移動
const circles = document.querySelectorAll('.bg-circle');
circles.forEach((circle, i) => {
gsap.to(circle, {
y: (i + 1) * -100, // 第 1 個移動 -100px,第 2 個 -200px...
ease: 'none', // 線性移動
scrollTrigger: {
trigger: document.body,
start: 'top top',
end: 'bottom bottom',
scrub: 1 // 與滾動同步,1 秒延遲
}
});
});
}
Scrub 參數說明:
scrub: true - 完全同步滾動scrub: 1 - 1 秒延遲,更平滑scrub: 0.5 - 0.5 秒延遲function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// 視窗大小改變時重新計算 ScrollTrigger
window.addEventListener('resize', debounce(() => {
ScrollTrigger.refresh();
}, 250));
為什麼需要 Debounce?
resize 事件在拖動視窗時會連續觸發數百次themes/fast-theme/source/css/style.css)本主題靈感來自《Cyberpunk 2077》遊戲的視覺風格:
:root {
/* 主色系 */
--primary: #05d9e8; /* 霓虹青 */
--primary-dark: #01a7b5;
--primary-light: #65f0ff;
--accent: #ff2a6d; /* 霓虹粉 */
--accent-light: #ff6b9d;
/* 文字色彩 */
--text: #e0e0e0; /* 主要文字 */
--text-light: #a0a0a0; /* 次要文字 */
--text-muted: #707070; /* 輔助文字 */
/* 背景色彩 */
--bg: #0d0221; /* 深紫黑背景 */
--bg-card: rgba(13, 2, 33, 0.95); /* 卡片背景(半透明) */
/* 邊框與陰影 */
--border: rgba(5, 217, 232, 0.3);
--shadow-sm: 0 0 5px rgba(5, 217, 232, 0.2);
--shadow: 0 0 15px rgba(5, 217, 232, 0.3);
--shadow-lg: 0 0 30px rgba(5, 217, 232, 0.4);
/* 漸層 */
--gradient: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
/* Cyberpunk 專用 */
--cyber-cyan: #05d9e8;
--cyber-pink: #ff2a6d;
--cyber-yellow: #f9f002;
--cyber-purple: #d300c5;
--neon-glow: 0 0 10px var(--cyber-cyan), 0 0 20px var(--cyber-cyan);
--pink-glow: 0 0 10px var(--cyber-pink), 0 0 20px var(--cyber-pink);
}
為什麼使用 CSS 變數?
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
/* 底部漸層 */
radial-gradient(ellipse at bottom, #1b2735 0%, var(--bg) 100%),
/* 水平網格線 */
linear-gradient(rgba(5, 217, 232, 0.03) 1px, transparent 1px),
/* 垂直網格線 */
linear-gradient(90deg, rgba(5, 217, 232, 0.03) 1px, transparent 1px);
background-size: 100% 100%, 40px 40px, 40px 40px;
z-index: -1;
pointer-events: none; /* 不影響點擊 */
}
多層背景技巧:
第 1 層:radial-gradient → 底部發光效果
第 2 層:linear-gradient → 水平線(每 40px 一條)
第 3 層:linear-gradient → 垂直線(每 40px 一條)
↓
疊加形成網格效果
.site-header {
background: rgba(13, 2, 33, 0.95); /* 半透明背景 */
backdrop-filter: blur(20px); /* 背景模糊 */
-webkit-backdrop-filter: blur(20px); /* Safari 支援 */
border-bottom: 1px solid var(--border);
padding: 20px 0;
position: sticky; /* 黏性定位 */
top: 0;
z-index: 100;
transition: var(--transition);
box-shadow: 0 0 20px rgba(5, 217, 232, 0.2); /* 霓虹光暈 */
}
/* 滾動後的狀態 */
.site-header.scrolled {
padding: 12px 0; /* 縮小 padding */
box-shadow: var(--neon-glow); /* 增強光暈 */
}
backdrop-filter 說明:
blur(20px) 產生毛玻璃效果.post {
background: var(--bg-card);
border-radius: var(--radius);
padding: 32px;
border: 1px solid var(--border);
position: relative;
overflow: hidden;
}
/* 頂部霓虹邊框 */
.post::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg,
var(--cyber-cyan),
var(--cyber-pink),
var(--cyber-cyan)
);
background-size: 200% 100%;
animation: borderGlow 3s linear infinite;
}
@keyframes borderGlow {
0% { background-position: 0% 0%; }
100% { background-position: 200% 0%; }
}
/* 懸停時邊框變粗 */
.post:hover::before {
height: 3px;
}
動畫原理:
背景寬度設為 200%,包含完整的漸層循環
↓
動畫將 background-position 從 0% 移動到 200%
↓
視覺上看起來像是顏色在流動
.read-more {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 20px;
padding: 12px 24px;
background: transparent; /* 透明背景 */
border: 2px solid var(--cyber-cyan); /* 霓虹邊框 */
color: var(--cyber-cyan) !important;
font-weight: 600;
font-size: 14px;
border-radius: var(--radius);
transition: var(--transition);
text-transform: uppercase; /* 全大寫 */
letter-spacing: 2px; /* 字距加寬 */
font-family: 'Orbitron', monospace; /* 科技感字體 */
}
.read-more:hover {
background: var(--cyber-cyan); /* 填滿背景 */
color: var(--bg) !important; /* 文字變深色 */
box-shadow: var(--neon-glow); /* 發光效果 */
transform: translateX(4px); /* 向右移動 */
}
/* 箭頭動畫 */
.read-more::after {
content: '→';
transition: transform 0.3s ease;
}
.read-more:hover::after {
transform: translateX(4px); /* 箭頭額外移動 */
}
.post-content pre {
background: #1d1f21 !important; /* 深色背景 */
color: #c5c8c6;
padding: 0;
border-radius: var(--radius);
overflow: hidden;
margin: 24px 0;
box-shadow: var(--shadow-lg);
font-family: 'JetBrains Mono', 'SF Mono', 'Consolas', monospace;
position: relative;
}
/* macOS 風格的視窗裝飾 */
.post-content pre::before {
content: '';
display: block;
height: 32px;
background: #2d2f31;
border-bottom: 1px solid #3c3d3f;
}
.post-content pre::after {
content: '● ● ●';
position: absolute;
top: 8px;
left: 12px;
font-size: 10px;
letter-spacing: 4px;
color: #ff5f56; /* 紅色 */
text-shadow:
12px 0 #ffbd2e, /* 黃色 */
24px 0 #27ca40; /* 綠色 */
}
視覺效果:
┌─────────────────────────────────────┐
│ ● ● ● │ ← 模擬 macOS 視窗按鈕
├─────────────────────────────────────┤
│ function hello() { │
│ console.log('Hello, World!'); │
│ } │
└─────────────────────────────────────┘
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--cyber-cyan);
border-radius: 4px;
box-shadow: 0 0 10px var(--cyber-cyan); /* 發光效果 */
}
::-webkit-scrollbar-thumb:hover {
background: var(--cyber-pink); /* 懸停變粉色 */
box-shadow: 0 0 10px var(--cyber-pink);
}
/* 平板裝置 */
@media (max-width: 1200px) {
.post-page .container {
max-width: 85%;
width: 85%;
}
}
/* 手機裝置 */
@media (max-width: 768px) {
.container {
padding: 0 16px;
}
.post-page .container {
max-width: 100%;
width: 100%;
}
.header-content {
flex-direction: column; /* 垂直排列 */
align-items: flex-start;
gap: 16px;
}
.site-nav {
width: 100%;
justify-content: flex-start;
}
.post {
padding: 24px;
margin-bottom: 24px;
}
.post-title {
font-size: 22px; /* 縮小標題 */
}
/* 文章導航改為單欄 */
.post-nav {
grid-template-columns: 1fr;
}
/* 系列卡片改為單欄 */
.series-grid {
grid-template-columns: 1fr;
gap: 20px;
}
}
斷點選擇說明:
| 斷點 | 裝置類型 | 主要調整 |
|---|---|---|
1200px |
平板橫向 | 內容寬度 85% |
992px |
平板直向 | 系列卡片單欄 |
768px |
手機 | 全寬、縮小字體 |
| 類別 | 技術 | 用途 |
|---|---|---|
| 爬蟲 | Node.js + https | 抓取 iThome 文章 |
| 轉換 | 正則表達式 | HTML → Markdown |
| 動畫 | GSAP + ScrollTrigger | 專業級頁面動畫 |
| 樣式 | CSS Variables + Flexbox/Grid | Cyberpunk 主題 |
| 部署 | AWS S3 + CloudFront | 靜態網站託管 |
prefers-reduced-motion 設定Hexo_parser/
├── _config.yml # Hexo 主配置
├── package.json # NPM 依賴
├── source/
│ ├── _posts/ # Markdown 文章
│ └── about/ # 關於頁面
├── themes/fast-theme/
│ ├── _config.yml # 主題配置
│ ├── layout/
│ │ ├── index.ejs # 首頁模板
│ │ ├── post.ejs # 文章模板
│ │ ├── archive.ejs # 歸檔模板
│ │ └── partial/
│ │ ├── header.ejs
│ │ ├── footer.ejs
│ │ └── article.ejs
│ └── source/
│ ├── css/
│ │ └── style.css # 主樣式檔
│ └── js/
│ ├── main.js # 基礎功能
│ └── gsap-animations.js # GSAP 動畫
├── tools/
│ ├── 爬取ithome文章.js # 單篇爬蟲
│ ├── 批量爬取系列文章.js # 批量爬蟲
│ ├── 添加文章分類.js # 分類管理
│ └── deploy-to-s3-sync.js # S3 部署
└── public/ # 生成的靜態檔案