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

引子

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

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

最终效果

去看看

为何是"伪"纯静态?

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

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

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

准备工作

  • cloudflare账号
  • 域名

整体架构流程图

graph TD A[豆瓣RSS订阅] --> B[rss-2-json.worker.js
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

流程说明

  1. 数据源:豆瓣RSS订阅提供电影动态数据
  2. 数据处理:RSS转JSON API将feed转换为结构化数据
  3. 图片处理:图片代理服务绕过防盗链限制
  4. 前端展示:静态页面整合数据,展示观影动态

架构优势

  • 完全静态化,可部署在任何静态托管平台
  • 利用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,代码如下:

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解决了两个关键问题:

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

接下来,我们需要解决图片访问的问题。由于豆瓣的图片有防盗链机制,直接引用会返回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配置一个自定义域名(如果想在国内用)

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

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

现在我们已经有了数据源(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='&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 部分

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>
相关推荐
ZXT2 分钟前
promise & async await总结
前端
Jerry说前后端2 分钟前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天10 分钟前
A12预装app
linux·服务器·前端
77238937 分钟前
解决 Microsoft Edge 显示“由你的组织管理”问题
前端·microsoft·edge
烛阴1 小时前
前端必会:如何创建一个可随时取消的定时器
前端·javascript·typescript
JarvanMo1 小时前
Swift 应用在安卓系统上会怎么样?
前端
LinXunFeng1 小时前
Flutter - 详情页 TabBar 与模块联动?秒了!
前端·flutter·开源
萌萌哒草头将军2 小时前
Oxc 最新 Transformer Alpha 功能速览! 🚀🚀🚀
前端·javascript·vue.js
Justinc.2 小时前
HTML5新增属性
前端·html·html5
1024小神3 小时前
nextjs项目build导出静态文件
前端·javascript