
引子
引子:不知道啥时候douban的js api失效了,不得不手搓一个。
搜了一下发现douban的feed还能用,如www.douban.com/feed/people...,里面该有的都有:海报,个人评分,个人tag,个人短评。但是feed不能直接作为api使用,需要转换成json格式并处理cors问题。然后图片需要通过代理解决防盗链的问题。
最终效果

为何是"伪"纯静态?
标题中之所以用到"伪"字,是因为这个方案虽然部署在静态博客上,但并非"纯粹"的静态。一个纯静态的页面,其所有内容在网站构建时就已经完全确定,无需在用户浏览器端再请求外部数据。
而我们的影单页面,虽然主体是静态的 HTML,但其核心数据(电影列表)和图片都是通过 JavaScript 在用户访问时,实时从我们部署在 Cloudflare 上的两个 Worker 服务动态获取的。这两个 Worker 扮演了轻量级后端(Serverless)的角色。
因此,这是一个前端静态,但功能动态的混合方案,故称之为"伪"纯静态。
准备工作:
- cloudflare账号
- 域名
整体架构流程图
RSS转JSON API] B --> C[JSON数据] C --> D[douban-rss.js
前端页面] A --> E[豆瓣图片资源] E --> F[cf-p.worker.js
图片代理服务] F --> G[代理后的图片] G --> D D --> H[用户浏览器] subgraph "Cloudflare Workers" B F end subgraph "静态网站" D end subgraph "数据流向" A C G end
流程说明:
- 数据源:豆瓣RSS订阅提供电影动态数据
- 数据处理:RSS转JSON API将feed转换为结构化数据
- 图片处理:图片代理服务绕过防盗链限制
- 前端展示:静态页面整合数据,展示观影动态
架构优势:
- 完全静态化,可部署在任何静态托管平台
- 利用Cloudflare全球CDN网络,访问速度快
- 各服务模块化设计,便于维护和扩展
为何要用自定义域名?
Cloudflare Workers 默认提供的 *.workers.dev
域名在国内的访问性不稳定,有时甚至会被屏蔽。为了确保我们创建的 API 服务和图片代理服务能够被长期、稳定地访问,绑定一个我们自己的域名是最佳实践。这不仅提升了可用性,也让服务的地址看起来更专业。
在这个架构中,以下两个地方需要用到自定义域名:
- RSS 转 JSON API 服务 :在 Cloudflare Worker 配置中,将
rss-2-json.worker.js
绑定到一个子域名,例如rss-api.yourdomain.com
。 - 图片代理服务 :同样地,将
cf-p.worker.js
绑定到另一个子域名,例如img-proxy.yourdomain.com
。
相应地,前端 douban-rss.js
脚本中的 RSS_JSON_URL
和 IMAGE_PROXY_URL
这两个常量也需要更新为这两个自定义域名地址。
第一步:rss => json API
先利用cloudflare worker写一个rss转json的api,代码如下:
js
/**
* Douban RSS to JSON Converter - Cloudflare Worker
*
* 📖 功能说明
* 将豆瓣 RSS 订阅转换为 JSON 格式的 API 服务,支持跨域访问和智能缓存
*
* 🔒 安全特性
* - 严格的域名白名单:只允许 yourdomain.com 和 localhost 访问
* - 双重验证机制:同时检查 Origin 和 Referer 头
* - URL 白名单:只允许访问 douban.com 域名,防止 SSRF 攻击
* - HTTPS 优先:生产环境优先使用 HTTPS
*
* ⚡ 性能优化
* - 多层缓存策略:
* • 浏览器缓存:30分钟 (max-age=1800)
* • CDN缓存:1小时 (s-maxage=3600)
* • Cloudflare 边缘缓存:全球分布式缓存
* - ETag 支持:避免不必要的数据传输
* - 请求超时控制:10秒超时,防止长时间等待
* - 异步缓存写入:不阻塞响应
*
* 🛠️ 技术特性
* - CORS 支持:完整的跨域请求支持
* - 错误处理:区分不同类型的错误(超时、网络错误等)
* - 结构化日志:只记录错误和安全事件,避免日志泛滥
* - 响应头调试:X-Cache-Status、X-Data-Source 等调试信息
*
* 📊 缓存工作流程
* 1. 检查 Cloudflare 缓存 → 有缓存直接返回
* 2. 无缓存时访问豆瓣 RSS
* 3. 解析并缓存数据
* 4. 返回 JSON 格式数据
*
* 🔍 使用方法
* GET /?feed={豆瓣RSS链接}
*
* 📈 响应头说明
* - X-Cache-Status: HIT/MISS - 缓存命中状态
* - X-Data-Source: douban-fresh - 数据来源标识
* - X-Cache-Date: 缓存时间戳
*
* 🚀 性能预期
* - 第一次请求:~2-3秒(需要访问豆瓣)
* - 缓存命中:~50-100ms(直接返回)
* - 1小时内相同请求不会访问豆瓣服务器
*
* @author Your Name
* @version 2.0
*/
// 常量定义
const CONSTANTS = {
ALLOWED_ORIGINS: [
"https://yourdomain.com",
"https://www.yourdomain.com",
"http://localhost:4000",
"http://localhost:3000",
"http://127.0.0.1:4000",
"http://127.0.0.1:3000"
],
ALLOWED_DOMAIN: 'douban.com',
REQUEST_TIMEOUT: 10000,
CACHE_TTL: 1800,
CDN_CACHE_TTL: 3600,
USER_AGENT: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"
};
// 工具函数
function hashCode(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 转换为32位整数
}
return Math.abs(hash).toString(36);
}
function createCorsHeaders(origin) {
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
}
function isValidOrigin(origin) {
return origin && (
CONSTANTS.ALLOWED_ORIGINS.includes(origin) ||
/^https?:\/\/localhost:\d+$/.test(origin) ||
/^https?:\/\/127\.0\.0\.1:\d+$/.test(origin)
);
}
function isValidReferer(referer) {
return !referer ||
referer.includes("yourdomain.com") ||
referer.includes("localhost") ||
referer.includes("127.0.0.1");
}
function logRequest(request, origin, status, message = '') {
// 在生产环境中,只记录重要事件(错误和安全事件)
const shouldLog = status >= 400 || message.includes('denied') || message.includes('error');
if (shouldLog) {
const log = {
timestamp: new Date().toISOString(),
method: request.method,
origin: origin || 'unknown',
status: status,
message: message
};
console.log(`[Worker] ${JSON.stringify(log)}`);
}
}
export default {
async fetch(request) {
// 安全检查
const origin = request.headers.get("Origin");
const referer = request.headers.get("Referer");
if (!isValidOrigin(origin) || !isValidReferer(referer)) {
logRequest(request, origin, 403, 'Access denied - invalid origin or referer');
return new Response("Access denied", {
status: 403,
headers: {
"Content-Type": "text/plain",
"X-Debug-Info": `Origin: ${origin || 'null'}, Referer: ${referer || 'null'}`,
},
});
}
// 处理 CORS 预检请求
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: createCorsHeaders(origin),
});
}
const url = new URL(request.url);
const feedUrl = url.searchParams.get("feed");
if (!feedUrl) {
return new Response("Missing ?feed parameter", {
status: 400,
headers: { "Access-Control-Allow-Origin": origin },
});
}
// URL安全验证 - 只允许豆瓣域名
try {
const parsedFeedUrl = new URL(feedUrl);
if (!parsedFeedUrl.hostname.endsWith(CONSTANTS.ALLOWED_DOMAIN)) {
return new Response(`Invalid feed URL: only ${CONSTANTS.ALLOWED_DOMAIN} domains are allowed`, {
status: 400,
headers: { "Access-Control-Allow-Origin": origin },
});
}
} catch (error) {
return new Response("Invalid feed URL format", {
status: 400,
headers: { "Access-Control-Allow-Origin": origin },
});
}
const cache = caches.default;
const cacheKey = new Request(request.url, request);
let cached = await cache.match(cacheKey);
if (cached) {
// 从缓存返回时也加 CORS 头
const newHeaders = new Headers(cached.headers);
newHeaders.set("Access-Control-Allow-Origin", origin);
newHeaders.set("X-Cache-Status", "HIT");
newHeaders.set("X-Cache-Date", cached.headers.get("Last-Modified") || "unknown");
return new Response(cached.body, {
status: cached.status,
statusText: cached.statusText,
headers: newHeaders,
});
}
// 添加超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONSTANTS.REQUEST_TIMEOUT);
let resp;
try {
resp = await fetch(feedUrl, {
signal: controller.signal,
headers: {
"User-Agent": CONSTANTS.USER_AGENT,
Referer: "https://www.douban.com/",
"Accept": "application/rss+xml, application/xml, text/xml",
"Accept-Encoding": "gzip, deflate",
},
});
clearTimeout(timeoutId);
if (!resp.ok) {
return new Response(`Failed to fetch feed: ${resp.status}`, {
status: resp.status,
headers: { "Access-Control-Allow-Origin": origin },
});
}
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
return new Response("Request timeout", {
status: 408,
headers: { "Access-Control-Allow-Origin": origin },
});
}
return new Response(`Network error: ${error.message}`, {
status: 500,
headers: { "Access-Control-Allow-Origin": origin },
});
}
const xml = await resp.text();
// 简单用正则抽取 <item>...</item>
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) !== null) {
const itemXml = match[1];
// 简单抽取字段
const getTag = (tag) => {
const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`);
const m = itemXml.match(re);
return m ? m[1].trim() : "";
};
// 解析 description 里的 HTML CDATA,提取电影详情链接、海报、推荐文字等
const description = getTag("description");
// 从 description 中提取通用信息
const linkMatch = description.match(/<a href="([^"]+)"/);
const posterMatch = description.match(/<img src="([^"]+)"/);
// 从 description 的 <p> 标签中提取详细信息
const pTags = description.match(/<p>([\s\S]*?)<\/p>/g) || [];
let recommend = '';
let tags = [];
let remark = '';
pTags.forEach(p => {
const content = p.replace(/<\/?p>/g, '').trim();
if (content.startsWith('推荐:')) {
recommend = content.substring('推荐:'.length).trim();
} else if (content.startsWith('标签:')) {
tags = content.substring('标签:'.length).trim().split(/\s+/).filter(t => t);
} else if (content.startsWith('备注:')) {
remark = content.substring('备注:'.length).trim();
}
});
items.push({
title: getTag("title"),
link: getTag("link"),
pubDate: getTag("pubDate"),
guid: getTag("guid"),
description,
movieLink: linkMatch ? linkMatch[1] : "",
poster: posterMatch ? posterMatch[1] : "",
recommend: recommend,
tags: tags,
remark: remark,
});
}
const jsonResp = new Response(JSON.stringify(items, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": origin,
"Cache-Control": `public, max-age=${CONSTANTS.CACHE_TTL}, s-maxage=${CONSTANTS.CDN_CACHE_TTL}`,
"ETag": `"${hashCode(JSON.stringify(items))}"`, // 添加ETag支持
"Last-Modified": new Date().toUTCString(),
"X-Cache-Status": "MISS", // 表示这是新数据
"X-Data-Source": "douban-fresh", // 表示数据来源
},
});
// 异步缓存,不阻塞响应
cache.put(cacheKey, jsonResp.clone()).catch(err => console.error('Cache error:', err));
// 成功请求不记录日志,避免日志过多
return jsonResp;
},
};
PS: 搞定好之后,给worker配置一个自定义域名(如果想在国内用)
这一步的成果:我们得到了一个可以将豆瓣RSS转换为JSON格式的API服务。这个API解决了两个关键问题:
- 将XML格式的RSS转换为前端可以直接使用的JSON数据
- 提供了缓存机制,减少对豆瓣服务器的请求压力
接下来,我们需要解决图片访问的问题。由于豆瓣的图片有防盗链机制,直接引用会返回403错误,所以我们需要一个图片代理服务。
第二步:图片代理
js
/**
* @file cf-p.worker.js
* @author Vincent He
* @version 2.0
*
* @description
* Cloudflare Worker acting as a secure, caching image proxy. It's designed to
* proxy images from whitelisted domains (like doubanio.com), providing hotlink
* protection and leveraging Cloudflare's powerful edge caching for performance.
*
* ---
*
* 📖 功能特性
* - 安全代理: 只允许代理白名单中的域名,防止被用作开放代理。
* - 防盗链: 检查请求的 Origin 和 Referer 头,只允许授权的网站嵌入图片。
* - 强力缓存:
* - 浏览器缓存长达 1 年 (max-age)。
* - Cloudflare CDN 缓存 30 天 (s-maxage),确保源站压力最小化。
* - CORS 支持: 允许跨域请求图片,适用于在不同域名的网站上展示。
* - 调试信息: 在响应头中添加 X-Cache-Status 等信息,方便调试。
*
* ---
*
* 🛠️ 使用方法
* GET /?url={要代理的图片URL}
*
* 例如:
* /?url=https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2921024298.jpg
*
* ---
*
* 🔗 依赖项
* - 无外部 JS 依赖。
* - 需要在 Cloudflare Worker环境中部署。
*/
const CONSTANTS = {
// 允许访问此代理的来源网站
ALLOWED_ORIGINS: [
"https://yourdomain.com",
"https://www.yourdomain.com",
"http://localhost:4000",
"http://localhost:3000",
"http://127.0.0.1:4000",
"http://127.0.0.1:3000"
],
// 允许代理的目标图片域名
ALLOWED_DOMAINS: [
'doubanio.com',
'douban.com',
],
BROWSER_CACHE_TTL: 31536000, // 1 year
CDN_CACHE_TTL: 2592000, // 30 days
USER_AGENT: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"
};
// --- 工具函数 ---
function createCorsHeaders(origin) {
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
}
function isValidOrigin(origin) {
// If the Origin header is not present, we don't block based on it.
// We will rely on the Referer check for security.
if (!origin) {
return true;
}
// If Origin is present, it must be from an allowed source.
return CONSTANTS.ALLOWED_ORIGINS.some(allowed => origin.startsWith(allowed));
}
function isValidReferer(referer) {
if (!referer) return false; // 严格要求有 Referer
return CONSTANTS.ALLOWED_ORIGINS.some(allowed => referer.startsWith(allowed));
}
// --- Worker 主逻辑 ---
export default {
async fetch(request, env, ctx) {
const origin = request.headers.get("Origin");
const referer = request.headers.get("Referer");
// 安全检查: 验证 Origin 和 Referer
if (!isValidOrigin(origin) || !isValidReferer(referer)) {
return new Response("Access denied. Invalid Origin or Referer.", {
status: 403,
headers: { "Content-Type": "text/plain" },
});
}
// 处理 CORS 预检请求
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: createCorsHeaders(origin),
});
}
const url = new URL(request.url);
const targetUrlStr = url.searchParams.get("url");
if (!targetUrlStr) {
return new Response("Missing ?url parameter", { status: 400 });
}
let targetUrl;
try {
targetUrl = new URL(targetUrlStr);
} catch (e) {
return new Response("Invalid ?url parameter", { status: 400 });
}
// 安全检查: 验证目标 URL 是否在白名单中
const isAllowedDomain = CONSTANTS.ALLOWED_DOMAINS.some(domain => targetUrl.hostname.endsWith(domain));
if (!isAllowedDomain) {
return new Response(`Proxying for domain ${targetUrl.hostname} is not allowed.`, { status: 403 });
}
// 缓存检查
const cache = caches.default;
const cacheKey = new Request(request.url, request);
let response = await cache.match(cacheKey);
if (response) {
// 缓存命中,添加 CORS 和调试头后返回
const newHeaders = new Headers(response.headers);
newHeaders.set("Access-Control-Allow-Origin", origin);
newHeaders.set("X-Cache-Status", "HIT");
newHeaders.set("X-Cache-Date", response.headers.get("Last-Modified") || new Date().toUTCString());
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}
// 缓存未命中,从源站获取
const originResponse = await fetch(targetUrl.toString(), {
headers: {
"User-Agent": CONSTANTS.USER_AGENT,
"Referer": "https://movie.douban.com/", // 伪造 Referer 以防源站防盗链
},
});
if (!originResponse.ok) {
return new Response(`Failed to fetch image: ${originResponse.status}`, {
status: originResponse.status,
headers: { "Access-Control-Allow-Origin": origin },
});
}
// 创建新的响应以便修改头部
response = new Response(originResponse.body, {
status: originResponse.status,
statusText: originResponse.statusText,
headers: originResponse.headers, // 先复制源站的所有头
});
// 设置我们自己的 CORS 和缓存策略
response.headers.set("Access-Control-Allow-Origin", origin);
response.headers.set("Cache-Control", `public, max-age=${CONSTANTS.BROWSER_CACHE_TTL}, s-maxage=${CONSTANTS.CDN_CACHE_TTL}`);
response.headers.set("X-Cache-Status", "MISS");
response.headers.set("Last-Modified", new Date().toUTCString());
// 异步写入缓存
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
},
};
PS: 同样,搞定好之后,给worker配置一个自定义域名(如果想在国内用)
这一步的成果:我们得到了一个安全的图片代理服务,它可以:
- 绕过豆瓣的防盗链限制,让图片正常显示
- 利用Cloudflare的全球CDN加速图片加载
- 提供长期缓存,减少重复请求
现在我们已经有了数据源(RSS转JSON API)和图片代理服务,接下来就可以构建前端展示页面了。这个页面将调用我们刚才创建的两个服务,实现完整的观影动态展示功能。
第三步:博客静态影单页面
js 部分
js
/**
* @file douban-rss.js
* @author Vincent He
* @version 2.0
*
* @description
* 这个脚本用于在静态网站上展示个人的豆瓣观影动态。它通过一个 Cloudflare Worker
* 将豆瓣的 RSS feed 转换为 JSON 格式,然后前端获取该 JSON 数据并渲染成美观的
* 电影卡片列表。这是一个完全前端驱动的、无需后端服务器的解决方案。
*
* ---
*
* 📖 功能特性
* - 动态数据获取:从指定的 JSON API 获取最新的豆瓣动态。
* - 纯静态实现:无需服务器端渲染,可部署在任何静态托管平台(如 GitHub Pages)。
* - 响应式布局:电影卡片列表能自适应不同屏幕尺寸。
* - 懒加载与代理:图片通过代理加载,提升访问速度和稳定性。
* - 易于配置:只需修改文件底部的几个常量即可完成部署。
*
* ---
*
* 🛠️ 使用方法
* 1. 在需要展示的 HTML 页面中,放置一个容器元素,例如:
* <div id="douban-container"></div>
*
* 2. 引入此 douban-rss.js 文件。
*
* 3. (可选)根据需要,修改文件底部的配置常量:
* - RSS_JSON_URL: 提供豆瓣数据的 JSON API 地址。
* - IMAGE_PROXY_URL: 图片代理服务的地址。
* - CONTAINER_ID: HTML 中容器元素的 ID。
* - MAX_ITEMS: 希望展示的最多项目数量。
*
* 4. 确保 `douban-rss.css` 文件被正确引入,以保证样式正常。
*
* ---
*
* 🔗 依赖项
* - `douban-rss.css`: 用于渲染卡片样式的 CSS 文件。
* - 后端 Worker (`rss-2-json.worker.js`): 一个将豆瓣 RSS 转换为 JSON 的服务。
* 该服务需要预先部署在 Cloudflare Workers 等平台上。
*
* ---
*
* 🔄 数据流
* 豆瓣 RSS -> Cloudflare Worker -> JSON API -> douban-rss.js -> HTML 渲染
*/
// 豆瓣 RSS 数据展示(纯静态方案)
class DoubanRSSParser {
constructor(rssJsonUrl, imageProxyUrl, containerId, maxItems = 9) {
this.rssJsonUrl = rssJsonUrl;
this.imageProxyUrl = imageProxyUrl;
this.containerId = containerId;
this.maxItems = maxItems;
}
async fetchDoubanData() {
try {
console.log("正在获取豆瓣数据...");
const response = await fetch(this.rssJsonUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log("获取到", data.length, "条数据");
// 限制数量并解析数据
const limitedData = data.slice(0, this.maxItems);
return limitedData.map((item) => this.parseItem(item));
} catch (error) {
console.error("获取豆瓣数据失败:", error);
return [];
}
}
parseItem(item) {
try {
// 从标题中提取动作和作品名称
const actionMatch = item.title.match(/^(看过|想看|在看)(.+)$/);
const action = actionMatch ? actionMatch[1] : "";
const workTitle = actionMatch ? actionMatch[2] : item.title;
return {
title: workTitle,
link: item.movieLink || item.link,
imageUrl: item.poster,
rating: item.recommend || "", // 直接使用 recommend 字段
remark: item.remark || "", // 直接使用 remark 字段
tags: item.tags || [], // 直接使用 tags 字段
action: action,
pubDate: new Date(item.pubDate),
};
} catch (error) {
console.error("解析项目失败:", error, item);
return null;
}
}
getRatingStars(rating) {
const ratingMap = {
很差: "⭐",
较差: "⭐⭐",
还行: "⭐⭐⭐",
推荐: "⭐⭐⭐⭐",
力荐: "⭐⭐⭐⭐⭐",
};
return ratingMap[rating] || "";
}
getProxiedImageUrl(imageUrl) {
if (!imageUrl) return "";
return `${this.imageProxyUrl}?url=${encodeURIComponent(imageUrl)}`;
}
generateHTML(items) {
if (!items || items.length === 0) {
return '<div class="douban-error">暂时无法加载豆瓣数据</div>';
}
let html = '<div class="douban-rss-container">';
html += '<div class="douban-grid">';
items.forEach((item) => {
if (!item) return; // 跳过解析失败的项目
// 安全地转义HTML字符
const safeTitle = this.escapeHtml(item.title);
const safeRemark = item.remark ? this.escapeHtml(item.remark) : "";
/*
看过
${item.action ? `<span class="douban-action">${item.action}</span>` : ''}
*/
html += `
<a href="${item.link}" target="_blank" class="douban-item-link">
<div class="douban-item">
<div class="douban-poster">
${
item.imageUrl
? `<img src="${this.getProxiedImageUrl(
item.imageUrl
)}" alt="${safeTitle}" onerror="this.style.display='none'; this.parentElement.innerHTML='<div class="no-image">暂无图片</div>';">`
: '<div class="no-image">暂无图片</div>'
}
</div>
<div class="douban-info">
<div class="douban-title">
<span class="douban-title-text">${safeTitle}</span>
</div>
${
item.rating
? `<div class="douban-rating">${this.getRatingStars(
item.rating
)} ${item.rating}</div>`
: ""
}
${
item.tags && item.tags.length > 0
? `<div class="douban-tags">${item.tags
.map(
(tag) =>
`<span class="douban-tag">${this.escapeHtml( tag )}</span>`
)
.join("")}</div>`
: ""
}
${
item.remark
? `<div class="douban-remark" title="${safeRemark}">${safeRemark}</div>`
: ""
}
<div class="douban-date">${item.pubDate.toLocaleDateString(
"zh-CN"
)}</div>
</div>
</div>
</a>
`;
});
html += "</div>";
html += "</div>";
return html;
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
async render() {
const container = document.getElementById(this.containerId);
if (!container) {
console.error(`容器 "${this.containerId}" 未找到`);
return;
}
// 显示加载中
container.innerHTML =
'<div class="douban-loading">正在加载豆瓣数据...</div>';
try {
const items = await this.fetchDoubanData();
const html = this.generateHTML(items);
container.innerHTML = html;
console.log("豆瓣数据渲染完成");
} catch (error) {
console.error("渲染豆瓣数据失败:", error);
container.innerHTML =
'<div class="douban-error">加载豆瓣数据失败,请稍后重试</div>';
}
}
}
// 页面加载完成后初始化
document.addEventListener("DOMContentLoaded", function () {
// --- 配置 ---
const RSS_JSON_URL = "https://rss-2-json.yourdomain.com/?feed=https://www.douban.com/feed/people/YOUR_USERNAME/interests";
const IMAGE_PROXY_URL = "https://img-proxy.yourdomain.com";
const CONTAINER_ID = "douban-container";
const MAX_ITEMS = 10; // 显示10条,和feed默认保持一致
// --- 配置结束 ---
const parser = new DoubanRSSParser(
RSS_JSON_URL,
IMAGE_PROXY_URL,
CONTAINER_ID,
MAX_ITEMS
);
parser.render();
});
css 部分
css
/* 豆瓣 RSS 展示样式 - 极简设计 */
.douban-rss-container {
margin: 30px 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
max-width: 1200px;
}
.douban-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
justify-content: start;
}
/* 响应式布局 - 自适应列数 */
@media (min-width: 1400px) {
.douban-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 30px;
}
}
@media (max-width: 768px) {
.douban-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 20px;
}
}
@media (max-width: 480px) {
.douban-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.douban-rss-container {
margin: 20px 0;
}
}
.douban-item-link {
text-decoration: none;
color: inherit;
display: block;
transition: transform 0.2s ease;
cursor: pointer;
}
.douban-item-link:hover {
/* 移除导致弹跳的上移动画 */
/* transform: translateY(-3px); */
}
.douban-item {
display: flex;
flex-direction: column;
background: transparent;
overflow: hidden;
height: 100%;
}
.douban-poster {
position: relative;
width: 100%;
/* 按照豆瓣海报比例 270:390 ≈ 0.69 */
aspect-ratio: 0.69;
overflow: hidden;
background: #f5f5f5;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease;
}
.douban-poster:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
}
.douban-poster img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.2s ease;
opacity: 0.9;
}
.douban-item-link:hover .douban-poster img {
opacity: 1;
}
.no-image {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #f0f0f0;
color: #999;
font-size: 12px;
transition: opacity 0.2s ease;
opacity: 0.9;
}
.douban-item-link:hover .no-image {
opacity: 1;
}
.douban-info {
padding: 12px 4px 0 4px;
flex: 1;
display: flex;
flex-direction: column;
}
.douban-title {
margin-bottom: 6px;
}
.douban-title-text {
color: #111;
font-weight: 400;
font-size: 14px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.douban-item-link:hover .douban-title-text {
color: #007722;
}
.douban-rating {
margin-bottom: 6px;
font-size: 12px;
color: #ffc107;
font-weight: 500;
}
.douban-tags {
margin-bottom: 6px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.douban-tag {
font-size: 11px;
line-height: 1.2;
color: #05802f;
background-color: #edf4ed;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
/* 移除默认的星星前缀,改用动态星级显示 */
.douban-action {
font-size: 10px;
color: #999;
background: #f8f8f8;
padding: 1px 4px;
border-radius: 2px;
margin-left: 6px;
display: inline-block;
}
.douban-remark {
margin-bottom: 8px;
font-size: 12px;
color: #666;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 4; /* 增加显示行数 */
-webkit-box-orient: vertical;
overflow: hidden;
position: relative;
}
.douban-date {
margin-top: auto;
font-size: 11px;
color: #ccc;
}
.douban-loading,
.douban-error {
text-align: center;
padding: 40px 20px;
color: #999;
font-size: 14px;
background: #f9f9f9;
border-radius: 8px;
margin: 20px 0;
}
.douban-error {
color: #d73a49;
background: #ffeef0;
}
/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
.douban-item {
background: transparent;
color: #e1e1e1;
}
.douban-poster {
background: #2d2d2d;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
.douban-poster:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.douban-title-text {
color: #e1e1e1;
}
.douban-item-link:hover {
.douban-title-text {
color: #4ade80;
}
.douban-remark[title] {
color: #ccc;
}
.douban-date {
color: #bbb;
}
}
.douban-rating {
color: #fbbf24;
}
.douban-remark {
color: #aaa;
}
.douban-date {
color: #666;
}
.no-image {
background: #2d2d2d;
color: #888;
}
.douban-loading {
background: transparent;
color: #aaa;
}
.douban-error {
background: transparent;
color: #ff6b6b;
}
.douban-action {
background: #333;
color: #ccc;
}
}
页面集成
html
<link rel="stylesheet" href="/movies/css/douban-rss.css">
<div id="douban-container">
<div class="douban-loading">正在加载豆瓣数据...</div>
</div>
<script src="/movies/js/douban-rss.js"></script>