利用豆瓣rss生成(伪)纯静态影单

banner

引子

引子:不知道啥时候douban的js api失效了,不得不手搓一个。

搜了一下发现douban的feed还能用,如https://www.douban.com/feed/people/stariveer/interests,里面该有的都有:海报,个人评分,个人tag,个人短评。但是feed不能直接作为api使用,需要转换成json格式并处理cors问题。然后图片需要通过代理解决防盗链的问题。

最终效果

最终效果

去看看

为何是“伪”纯静态?

标题中之所以用到“伪”字,是因为这个方案虽然部署在静态博客上,但并非“纯粹”的静态。一个纯静态的页面,其所有内容在网站构建时就已经完全确定,无需在用户浏览器端再请求外部数据。

而我们的影单页面,虽然主体是静态的 HTML,但其核心数据(电影列表)和图片都是通过 JavaScript 在用户访问时,实时从我们部署在 Cloudflare 上的两个 Worker 服务动态获取的。这两个 Worker 扮演了轻量级后端(Serverless)的角色。

因此,这是一个前端静态,但功能动态的混合方案,故称之为“伪”纯静态。

准备工作

  • cloudflare账号
  • 域名

整体架构流程图

graph TD
    A[豆瓣RSS订阅] --> B[rss-2-json.worker.js<br/>RSS转JSON API]
    B --> C[JSON数据]
    C --> D[douban-rss.js<br/>前端页面]
    
    A --> E[豆瓣图片资源]
    E --> F[cf-p.worker.js<br/>图片代理服务]
    F --> G[代理后的图片]
    G --> D
    
    D --> H[用户浏览器]
    
    subgraph "Cloudflare Workers"
        B
        F
    end
    
    subgraph "静态网站"
        D
    end
    
    subgraph "数据流向"
        A
        C
        G
    end

流程说明

  1. 数据源:豆瓣RSS订阅提供电影动态数据
  2. 数据处理rss-2-json.worker.js 将feed转换为结构化数据
  3. 图片处理cf-p.worker.js 绕过防盗链限制
  4. 前端展示douban-rss.js 整合数据,展示观影动态

架构优势

  • 完全静态化,可部署在任何静态托管平台
  • 利用Cloudflare全球CDN网络,访问速度快
  • 各服务模块化设计,便于维护和扩展

为何要用自定义域名?

Cloudflare Workers 默认提供的 *.workers.dev 域名在国内的访问性不稳定,有时甚至会被屏蔽。为了确保我们创建的 API 服务和图片代理服务能够被长期、稳定地访问,绑定一个我们自己的域名是最佳实践。这不仅提升了可用性,也让服务的地址看起来更专业。

在这个架构中,以下两个地方需要用到自定义域名:

  1. RSS 转 JSON API 服务:在 Cloudflare Worker 配置中,将 rss-2-json.worker.js 绑定到一个子域名,例如 rss-api.yourdomain.com
  2. 图片代理服务:同样地,将 cf-p.worker.js 绑定到另一个子域名,例如 img-proxy.yourdomain.com

相应地,前端 douban-rss.js 脚本中的 RSS_JSON_URLIMAGE_PROXY_URL 这两个常量也需要更新为这两个自定义域名地址。

第一步:rss => json API

先利用cloudflare worker写一个rss转json的api,代码如下:

rss-2-json.worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
/**
* 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解决了两个关键问题:

  1. 将XML格式的RSS转换为前端可以直接使用的JSON数据
  2. 提供了缓存机制,减少对豆瓣服务器的请求压力

接下来,我们需要解决图片访问的问题。由于豆瓣的图片有防盗链机制,直接引用会返回403错误,所以我们需要一个图片代理服务。

第二步:图片代理

cf-p.worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
/**
* @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配置一个自定义域名(如果想在国内用)

这一步的成果:我们得到了一个安全的图片代理服务,它可以:

  1. 绕过豆瓣的防盗链限制,让图片正常显示
  2. 利用Cloudflare的全球CDN加速图片加载
  3. 提供长期缓存,减少重复请求

现在我们已经有了数据源(RSS转JSON API)和图片代理服务,接下来就可以构建前端展示页面了。这个页面将调用我们刚才创建的两个服务,实现完整的观影动态展示功能。

第三步:博客静态影单页面

js 部分

douban-rss.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
/**
* @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='&lt;div class=&quot;no-image&quot;&gt;暂无图片&lt;/div&gt;';">`
: '<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 部分

douban-rss.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
/* 豆瓣 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;
}
}

页面集成

1
2
3
4
5
<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>