
Xiaohongshu Top Account
- 235 installs
- 316 repo stars
- Updated August 4, 2026
- redfox-data/redfox-community
Use xiaohongshu-top-account for development tasks
About
xiaohongshu-top-account: A skill for development. This provides functionality for development workflows.
- xiaohongshu-top-account
Xiaohongshu Top Account by the numbers
- 235 all-time installs (skills.sh)
- +16 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,678 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/redfox-data/redfox-community --skill xiaohongshu-top-accountAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 235 |
|---|---|
| repo stars | ★ 316 |
| Last updated | August 4, 2026 |
| Repository | redfox-data/redfox-community ↗ |
What it does
Use xiaohongshu-top-account for development tasks
Files
小红书热门账号推荐
1. 简介
小红书账号榜单追踪分析工具 -- 基于红狐数据 API,提供小红书全品类 / 分品类账号每日 / 每周 / 每月的表现榜单查询,支持排名查询、赛道筛选、HTML 可视化报告下载与定时订阅推送。
适用对象:内容创作者、品牌方、MCN 机构、行业分析师。
---
2. 功能特性
核心功能:
- 排名查询 -- 查日榜、周榜、月榜 TOP50
- 赛道筛选 -- 按品类筛选(美妆、美食、旅行、母婴、健身等 25 个赛道)
- 报告下载 -- 生成 HTML 可视化报告,支持导出 PDF / 截图
- 定时订阅 -- 设置自动化定时推送
特色亮点:
- 日榜每日 19:00 更新,周榜每周一 15:00 更新,月榜每月 2 号 9:00 更新
- 自动解析用户自然语言输入(时间、周期、赛道),无需记忆命令格式
- 排名算法:根据总粉丝数、粉丝增量、点赞增量、收藏增量、分享增量、评论增量加权计算,满分 100 分
- 数据回退机制:部分分类返回空数据时自动回退最多 3 天
- HTML 报告账号名均可点击跳转小红书主页
---
3. 一键安装
鉴权
获取 API Key
请前往 红狐hub 获取API KEY
配置 API Key
方案1: 以OpenClaw为例,将REDFOX_API_KEY添加到~/.openclaw/openclaw.json中
{ "env": { "REDFOX_API_KEY": "ak_xxxx..." } }方案2: 终端配置:export REDFOX_API_KEY="ak_xxxx..."
export REDFOX_API_KEY="ak_xxxx..."依赖安装
无需安装第三方依赖,使用 Python 标准库 urllib 发送请求即可。
---
4. 使用指南
基础使用
排名查询(推荐:自然语言模式):
直接传入用户原始问题,脚本自动完成全部解析:
python scripts/fetch_rank.py --query "用户原始问题" --limit 20 --html脚本自动解析逻辑:
- 时间判断与日期计算:
| 榜单类型 | 更新时间 | 当前时间 < 更新时间 | 当前时间 >= 更新时间 |
|---|---|---|---|
| 日榜 | 每日 19:00 | 取前2天 (offset=2) | 取前一天 (offset=1) |
| 周榜 | 每周一 15:00 | 取前2周 (offset=2) | 取前1周 (offset=1) |
| 月榜 | 每月2号 上午9:00 | 取前2月 (offset=2) | 取前1月 (offset=1) |
- 周期判断:自动从用户文本中识别日 / 周 / 月(无明确则默认日榜)
- 回溯范围校验:
| 榜单类型 | 最大回溯 | 超范围处理 |
|---|---|---|
| 日榜 | 近7天 | 自动切换至最近可用日期 |
| 周榜 | 近3周 | 自动切换至最近可用日期 |
| 月榜 | 近3个月 | 自动切换至最近可用日期 |
- 赛道(type)匹配:支持模糊匹配,常用映射:
| 用户说 | 映射到 |
|---|---|
| 健身、运动、瑜伽 | 体育锻炼 |
| 美妆、彩妆、护肤 | 化妆美容/个人护理 |
| 美食、探店、烹饪 | 美味佳肴 |
| 旅行、旅游 | 旅行度假 |
| 母婴、育儿 | 亲子育儿 |
| 穿搭、时尚 | 时尚穿搭 |
- 数据回退机制:部分分类(如体育锻炼)在某些日期可能返回空数据时,自动尝试前一个日期,最多回退 3 天。
严格输出顺序(必须按顺序执行)
第一步:内容输出 -- 调用 fetch_rank.py 查询并格式化输出榜单(Markdown 表格,最多 20 条)。
第二步:询问定时订阅 -- 输出完榜单后直接追加:
📬 订阅服务
1️⃣ 是否需要订阅每日/周/月的小红书账号最新排名,订阅后定时推送给您?
2️⃣ 是否需要订阅具体赛道的账号表现?我们支持:综合全部、出行代步、休闲爱好、影视娱乐、数码科技、医疗保健、综合杂项、星座情感、时尚穿搭、婚庆婚礼、拍摄记录、学习教育、化妆美容、居家装修、旅行度假、亲子育儿、个人护理、美味佳肴、职业发展、宠物天地、潮流鞋包、日常生活、科学探索、新闻资讯、体育锻炼用户回复 "需要订阅" 时,使用 calendar_create 工具创建日程。
第三步:交付 HTML 报告 -- 使用 deliver_attachments 交付脚本输出的 HTML 文件。
用户需要查看全部数据时:重新调用脚本获取完整 50 条数据,同时输出 Markdown 表格和交付 HTML 报告(两者缺一不可)。
输出格式
Markdown 表格模板:
📊 小红书{period_label} · {category_label}
数据日期:{date}
共 {total} 个账号上榜
💡 榜单说明:{period_update_time}更新昨日/上周/上月数据。
📐 排名算法:排名根据达人在小红书的**总粉丝数**、周期内的**粉丝增量**、**点赞增量**、**收藏增量**、**分享增量**以及**评论增量**加权计算所得,满分100分。
| 排名 | 账号名 | 综合评分 | 总粉丝数 | 新增笔记 | 新增粉丝 | 新增点赞 | 新增评论 | 新增收藏 | 新增分享 |
|:---:|--------|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|
| 🥇 1 | 账号A · 赛道 | 96 | 254.06w | 7 | 6919 | 24.64w | 6.68w | 2.53w | 13.39w |
| 🥈 2 | 账号B · 赛道 | 89 | 113.55w | 1 | 1.91w | 7.90w | 4787 | 2.18w | 3.12w |
| 🥉 3 | 账号C · 赛道 | 87 | 439.14w | 2 | 5859 | 16.51w | 993 | 7688 | 687 |
统计概览:上榜账号 {total} · 最高互动 {max_interaction} · 总新增笔记 {total_notes}高级使用
报告下载:查询时加 --html --limit 50 参数,脚本自动生成 HTML 报告。
定时订阅:
- 每日订阅:
FREQ=DAILY;BYHOUR=8;BYMINUTE=0 - 每周订阅:
FREQ=WEEKLY;BYDAY=MO;BYHOUR=8;BYMINUTE=0 - 每月订阅:
FREQ=WEEKLY;BYDAY=MO;BYHOUR=8;BYMINUTE=0;INTERVAL=4
automation prompt:查询小红书最新{period}榜(综合全部)并推送给用户
常用指令速查
| 用户意图 | 典型触发词 | 对应操作 |
|---|---|---|
| 排名查询 | 小红书日榜、周榜、月榜、TOP50、最夯账号、排行榜、最新推荐 | 调用 API 并格式化输出 |
| 领域查询 | 美妆类/美食类/旅行类/母婴类/健身类...小红书排名 | 调用 API 并按 category 筛选 |
| 报告下载 | 下载报告、导出榜单、生成报告 | 使用 --html 参数自动生成 HTML |
| 定时订阅 | 订阅日榜/周榜/月榜、定时推送、每天发给我 | 创建 WorkBuddy Automation |
API 参数说明
- URL:
POST https://redfox.hk/story/api/xhsData/query - 成功码:
code: 2000(非 0) - 认证方式:
X-API-KEYHeader(从环境变量REDFOX_API_KEY获取) - 请求参数:dateType(1=日榜, 2=周榜, 3=月榜)、rankDate(YYYY-MM-DD)、type(赛道名称)、source(固定为 "小红书指数榜")
- 月榜日期参数必须使用每月 2 号(如 "2026-04-02")
HTML 报告规范
表格列:排名 | 账号名 | 总粉丝数 | 新增笔记 | 新增粉丝 | 新增点赞 | 新增评论 | 新增收藏 | 新增分享
统计卡片:上榜账号、最高互动(点赞+评论+收藏+分享)、总新增笔记
---
5. 使用场景
1. 内容创作者找对标:输入 "小红书美妆日榜",获取当日美妆赛道 TOP 账号,分析其内容策略和数据表现,找到可参考的对标方向。 2. 品牌方筛选投放达人:查询 "上周母婴周榜",快速筛选高互动、高增长的母婴赛道达人,节省手动搜集数据的时间。 3. MCN 监控竞品动态:定期查询特定赛道榜单,追踪竞品账号的排名变化和增长趋势,及时调整运营策略。 4. 行业分析师产出报告:生成可视化 HTML 报告,一键导出 PDF,用于行业分析汇报或投资决策参考。
---
6. 项目架构
目录结构
xiaohongshu-top-account/
├── SKILL.md # 本文件
├── scripts/
│ ├── fetch_rank.py # 查询小红书榜单数据
│ └── generate_report.py # 被 fetch_rank.py --html 调用生成 HTML
└── references/
└── api_docs.md # API 详细参数说明技术栈
| 技术 | 用途 |
|---|---|
| Python 标准库 (urllib) | HTTP 请求与数据解析 |
| 红狐 API | 小红书榜单数据来源 |
| HTML / CSS | 可视化报告渲染 |
核心模块说明
- fetch_rank.py:接收用户查询参数(--query 或 --category/--period/--date),自动解析时间、周期和赛道,调用红狐 API 获取榜单数据,支持 --html 参数生成 HTML 报告。
- generate_report.py:读取榜单数据生成 HTML 报告模板,包含排名表格、统计卡片、榜单说明和排名算法。
资源索引
| 文件 | 用途 |
|---|---|
| scripts/fetch_rank.py | 查询小红书榜单数据 |
| scripts/generate_report.py | 被 fetch_rank.py --html 调用生成 HTML |
| references/api_docs.md | 需要查看 API 详细参数说明时读取 |
---
7. 常见问答
安装
Q: 需要安装什么依赖? A: 无需安装第三方依赖,使用 Python 标准库 urllib 即可。
Q: 如何配置 API Key? A: 请前往 红狐hub 获取 API KEY,通过环境变量 REDFOX_API_KEY 配置。
使用
Q: 日榜什么时候更新? A: 每日 19:00 更新。
Q: 支持哪些赛道? A: 支持 25 个赛道,完整列表见 references/api_docs.md。
Q: 赛道关键词匹配不上怎么办? A: 赛道关键词支持 LLM 语义模糊匹配,实在无法识别时降级为 "综合全部"。
Q: 查询不到数据怎么办? A: 部分分类(如体育锻炼)可能返回空数据,脚本会自动回退日期最多 3 天。
故障排除
Q: 最高互动数据为 0? A: 最高互动需要根据点赞 + 评论 + 收藏 + 分享计算,不能直接使用接口返回的 newInteraction 字段。
Q: HTML 报告生成失败? A: 确保 workspace 有写权限,且数据文件路径正确。
Xiaohongshu Top Account
---
Introduction
Quickly discover the most influential accounts on Xiaohongshu each day and stay on top of niche trends.
Core Value
Based on daily TOP 50 account data across all Xiaohongshu niches — follower growth, engagement, and growth trends — this skill helps you with topic decisions, benchmark accounts, competitor monitoring, and niche selection.
Who It's For
- 🎬 Content creators — See how top accounts perform in your niche and find benchmarks to learn from
- 🏢 Brands / ad buyers — Efficiently screen quality creators and evaluate partnership value
- 📦 MCN agencies / content teams — Track competitor dynamics and shape operations strategy
- 📊 Industry analysts — Get data insights and generate analysis reports
---
Features
Core Capabilities
- Ranking queries: Daily, weekly, and monthly TOP 50 account rankings; TOP 20 shown by default
- Niche filtering: Query across all hot niches on Xiaohongshu or drill into any of 25 track categories with fuzzy keyword matching
- Composite score: Weighted ranking from total followers, new followers, and new likes/saves/shares/comments — objective and credible, scored out of 100
- HTML report generation: Visual HTML reports with clickable account names linking to Xiaohongshu profiles; export as image or PDF
- Subscription push: Schedule daily, weekly, or monthly ranking updates, or subscribe by specific niche
---
API Key Acquisition & Security
- This skill requires the environment variable:
REDFOX_API_KEY. REDFOX_API_KEYis provided by RedFoxHub (https://redfox.hk).- Register at RedFoxHub to obtain your
REDFOX_API_KEY. - Configure
REDFOX_API_KEYas a device environment variable before using this skill. - Before providing your key, confirm its source, available scope, validity period, and whether reset/revocation is supported.
- Do not hard-code or expose the key in plaintext in code, prompts, logs, or output files.
---
Usage Guide
Describe what you need in plain language — no commands to memorize.
Quick phrase reference
| Intent | Example phrase | What you get |
|---|---|---|
| Daily ranking | "What's the latest Xiaohongshu daily account ranking?" | Latest all-niche daily TOP 20 |
| Weekly niche ranking | "How did food accounts rank on Xiaohongshu last week?" | Latest 美味佳肴 niche weekly TOP 20 |
| Monthly ranking | "Show me last month's beauty Xiaohongshu account ranking" | Latest 化妆美容 niche monthly TOP 20 |
| Full ranking | "I want to see all 50 entries" | Full TOP 50 with report |
| Download report | "Generate an HTML report for this ranking" | Visual HTML file delivered |
| Subscribe | "Send me daily food-niche Xiaohongshu ranking updates" | Scheduled push task created |
Sample output
📊 Xiaohongshu Daily Ranking · All niches
Data date: 2026-04-28 50 accounts on the list (showing TOP 20)
💡 Ranking note: Data updates daily at 19:00 with yesterday's figures; may differ from real-time data.
📐 Ranking algorithm: Weighted from total followers, period follower growth, likes, saves, shares, and comments; max score 100
| Rank | Account (clickable) | Score | Total followers | New posts | New followers | New likes | New comments | New saves | New shares |
|---|---|---|---|---|---|---|---|---|---|
| 🥇 1 | Account name | 96 | 2.54M | 7 | 6,919 | 246K | 67K | 25K | 134K |
| 🥈 2 | Account name | 89 | 1.13M | 1 | 19.1K | 79K | 4,787 | 22K | 31.2K |
| … | … | … | … | … | … | … | … | … | … |
⚡ More actions ⏺️ This ranking has 50 entries in total. View the remaining 30?
📬 Subscription 1️⃣ Subscribe to daily/weekly/monthly Xiaohongshu account ranking updates? 2️⃣ Subscribe to a specific niche?
---
Use Cases
| Scenario | Role | Example question | Benefit |
|---|---|---|---|
| Find niche benchmark accounts | Creator / solo operator | "Which beauty accounts are doing well? Show me the daily ranking" | Quickly spot category leaders and clarify direction |
| Evaluate creator partnerships | Brand / ad buyer | "Who stood out in last week's Xiaohongshu fashion TOP 50?" | Batch screening for faster partnership decisions |
| Monitor competitor niches | MCN / content team | "Which food accounts gained followers fastest this week?" | Track competitive landscape and respond quickly |
| Generate industry reports | Analyst / consultant | "Generate a March Xiaohongshu tech ranking report for me" | One-click visual HTML for sharing and presentations |
| Track ranking changes regularly | Operations team | "Send me the latest travel weekly ranking every Monday" | Automated ranking intelligence via subscription |
---
Important data notes
Update schedule and lookback
| Ranking type | Update time | Lookback |
|---|---|---|
| Daily | Daily 19:00 — yesterday's data | Past 7 days |
| Weekly | Monday 15:00 — last week's data | Past 3 weeks |
| Monthly | 2nd of month 9:00 — last month's data | Past 3 months |
Supported niches (25)
All categories, mobility, hobbies, film & entertainment, tech, healthcare, misc, astrology & emotions, fashion, weddings, photography, education, beauty, home decor, travel, parenting, personal care, food, career, pets, bags & shoes, daily life, science, news, sports & fitness
Composite score notes
Score is weighted: total followers (20%), new followers (20%), new likes (15%), new saves (15%), new shares (15%), new comments (15%); max 100. Uses log normalization to avoid top accounts dominating scores, making mid-tier rankings more reference-worthy.
Data freshness
Data is not real-time. Engagement figures reflect the collection moment and may differ from live platform data.
---
小红书热门账号推荐 / xiaohongshu-top-account
---
简介
帮你快速发现每日小红书平台最具影响力的账号,掌握赛道动态。
核心价值 基于每日小红书全赛道 TOP50 账号的涨粉、互动、增长趋势数据,帮你解决选题决策、对标账号、竞品监控、赛道选择四大核心问题。
适用对象
- 🎬 内容创作者 — 了解同赛道优秀账号表现,寻找对标和学习对象
- 🏢 品牌方/商务投放 — 高效筛选优质达人,评估合作价值
- 📦 MCN 机构/内容团队 — 掌握竞品动态,制定运营策略
- 📊 行业分析师 — 获取数据洞察,生成分析报告
---
功能特性
核心功能
- 排名查询:查询小红书账号日榜、周榜、月榜 TOP 50 账号,默认展示 TOP 20
- 赛道筛选:支持小红书全站热门、或按 25 个赛道品类精准查询,支持模糊词匹配
- 综合评分:根据总粉丝数、新增粉丝、新增点赞/收藏/分享/评论加权计算,多维指标排名客观可信,满分 100 分
- HTML 报告生成:生成带可视化布局的 HTML 文件,账号名可点击跳转小红书主页,支持导出图片或 PDF
- 订阅推送:支持设置日榜、周榜、月榜定时自动推送,也可按指定赛道订阅
---
密钥获取与安全说明
- 本技能需要使用环境变量:
REDFOX_API_KEY。 REDFOX_API_KEY由 红狐 hub (https://redfox.hk)提供。- 请前往 红狐 hub 注册账号,获取
REDFOX_API_KEY。 - 配置设备环境变量
REDFOX_API_KEY后使用本技能。 - 在提供密钥前,请先确认密钥来源、可用范围、有效期及是否支持重置/撤销。
- 禁止在代码、提示词、日志或输出文件中硬编码/明文暴露密钥。
---
使用指南
直接用自然语言描述你的需求,无需记忆任何命令格式。
常用说法速查
| 意图 | 示例话术 | 效果 |
|---|---|---|
| 查日榜 | "最新小红书账号日榜排名?" | 返回全品类最新日榜 TOP 20 |
| 查赛道周榜 | "上周小红书美食类账号排行怎么样?" | 返回美味佳肴赛道最新周榜 TOP 20 |
| 查月榜 | "帮我看看上个月美妆类小红书账号的榜单" | 返回化妆美容赛道最新月榜 TOP 20 |
| 查完整榜单 | "我想看完整的 50 条" | 展示完整 TOP 50 并生成报告 |
| 下载报告 | "帮我生成这次榜单的 HTML 报告" | 生成可视化 HTML 文件并交付 |
| 订阅推送 | "我想每天收到美食类小红书日榜更新" | 创建定时自动推送任务 |
输出示例
📊 小红书日榜 · 全品类
数据日期:2026-04-28 共 50 个账号上榜(展示 TOP 20 条)
💡 榜单说明:数据于每日 19:00 更新昨日数据,与实时数据存在差异
📐 排名算法:根据达人在小红书的总粉丝数、周期内粉丝增量、点赞增量、收藏增量、分享增量以及评论增量加权计算,满分 100
| 排名 | 账号名 | 综合评分 | 总粉丝数 | 新增笔记 | 新增粉丝 | 新增点赞 | 新增评论 | 新增收藏 | 新增分享 |
|---|---|---|---|---|---|---|---|---|---|
| 🥇 1 | 账号名(可点击) | 96 | 254w | 7 | 6919 | 24.6w | 6.7w | 2.5w | 13.4w |
| 🥈 2 | 账号名(可点击) | 89 | 113w | 1 | 1.9w | 7.9w | 4787 | 2.2w | 3.1w |
| … | … | … | … | … | … | … | … | … | … |
⚡ 更多操作 ⏺️ 本次榜单完整共 50 条数据,是否需要查看剩余 30 条?
📬 订阅服务 1️⃣ 是否需要订阅每日/周/月的小红书账号最新排名? 2️⃣ 是否需要订阅具体赛道的账号表现?
---
使用场景
| 场景 | 角色 | 示例问法 | 收益 |
|---|---|---|---|
| 寻找赛道标杆账号 | 内容创作者 / 个体运营 | "美妆类账号哪些做得好?帮我看看最新日榜" | 快速找到行业头部,明确内容方向 |
| 评估达人合作候选人 | 品牌方 / 商务投放 | "上周小红书穿搭类 TOP50 有哪些账号表现亮眼?" | 批量筛选,提升投放决策效率 |
| 监控竞品赛道动态 | MCN 机构 / 内容团队 | "最近一周美食账号有哪些涨粉最快?" | 实时掌握竞争格局,快速响应市场变化 |
| 生成行业分析报告 | 行业分析师 / 咨询顾问 | "帮我生成 3 月份小红书科技类榜单分析报告" | 一键生成可视化 HTML,方便汇报分享 |
| 定期追踪账号排名变化 | 运营团队 | "我想每周一收到旅行赛道的最新周榜" | 订阅推送,自动化榜单情报获取 |
---
重要数据说明
榜单更新时间与数据回溯
| 榜单类型 | 更新时间 | 可回溯范围 |
|---|---|---|
| 日榜 | 每日 19:00 更新昨日数据 | 过去 7 天 |
| 周榜 | 每周一 15:00 更新上周数据 | 过去 3 周 |
| 月榜 | 每月 2 号 9:00 更新上月数据 | 过去 3 个月 |
支持赛道(25 个)
综合全部、出行代步、休闲爱好、影视娱乐、数码科技、医疗保健、综合杂项、星座情感、时尚穿搭、婚庆婚礼、拍摄记录、学习教育、化妆美容、居家装修、旅行度假、亲子育儿、个人护理、美味佳肴、职业发展、宠物天地、潮流鞋包、日常生活、科学探索、新闻资讯、体育锻炼
综合评分说明
评分根据总粉丝数(20%)、新增粉丝(20%)、新增点赞(15%)、新增收藏(15%)、新增分享(15%)、新增评论(15%)加权计算,满分 100 分。采用对数归一化公式,避免头部账号过度拉分,使中腰部账号排名更具参考性。
数据时效
数据非实时更新,账号互动数据为采集时刻的真实数据,与实时数据存在差异。
小红书榜单 API 接口文档
接口基础信息
| 字段 | 值 |
|---|---|
| 接口地址 | https://redfox.hk/story/api/xhsData/query |
| 请求方式 | POST |
| Content-Type | application/json |
| 认证方式 | X-API-KEY Header(从环境变量 REDFOX_API_KEY 获取) |
鉴权
获取 API Key
请前往 红狐hub 获取API KEY
配置 API Key
方案1: 以OpenClaw为例,将REDFOX_API_KEY添加到~/.openclaw/openclaw.json中,部分内容如下:
{ "env": { "REDFOX_API_KEY": "ak_xxxx..." } }方案2: 终端配置
export REDFOX_API_KEY="ak_xxxx..."---
请求参数(已校验)
{
"dateType": 1, // 榜单日期类型:1=日榜 2=周榜 3=月榜(必填)
"rankDate": "2026-04-28", // 榜单日期 yyyy-MM-dd(必填)
// 日榜:填当日日期,如 2026-04-28
// 周榜:填该周**周一**日期,如 2026-04-20
// 月榜:填该月第一天,如 2026-03-01
"type": "体育锻炼", // 赛道分类,默认"综合全部"(必填)
"source": "小红书指数榜" // 固定值(必填)
}请求头
| Header | 值 | 说明 |
|---|---|---|
| Content-Type | application/json | 必填 |
| X-API-KEY | ak_xxxxxxxx | 从环境变量 REDFOX_API_KEY 获取,必填 |
⚠️ 重要:source必须为"小红书指数榜",使用其他值会导致接口返回空数据!
>
⚠️ 注意:page/pageSize参数无效,接口每次固定返回 50 条数据。
---
响应结构(实际返回)
{
"code": 2000,
"data": [
{
"accountLink": "https://www.xiaohongshu.com/user/profile/...",
"accountName": "恋与深空",
"accountRanking": 1,
"category": "数码科技",
"fansCount": "254.06w",
"fansGrowth": "6919",
"likedGrowth": "24.64w",
"commentsGrowth": "6.68w",
"collectedGrowth": "2.53w",
"sharedGrowth": "13.39w",
"newNoteCount": null,
"dataFetchTime": "2026-04-29 19:00:01",
"rankDate": "2026-04-28",
"rankPeriod": "日榜"
}
]
}⚠️ 注意:新接口不返回 comprehensiveScore(综合评分)字段;互动数各字段均为字符串格式,如"6.68w"、"1245"。
---
错误码
| code | 含义 |
|---|---|
| 2000 | 成功(注意:成功码是 2000 不是 0!) |
| 其他 | 错误,data 为空数组 |
---
字段映射(fetch_rank.py normalized JSON)
| 接口原始字段 | normalized 字段 | 含义 |
|---|---|---|
accountRanking | rank | 排名 |
accountName | accountName | 账号名 |
category | category | 赛道 |
fansCount | followers | 总粉丝数 |
newNoteCount | newNoteCount | 新增笔记数 |
fansGrowth | newFans | 新增粉丝 |
likedGrowth | newLikes | 新增点赞 |
commentsGrowth | newComments | 新增评论 |
collectedGrowth | newCollects | 新增收藏 |
sharedGrowth | newShares | 新增分享 |
accountLink | profileUrl | 主页链接 |
---
可用品类列表
综合全部
出行代步 / 休闲爱好 / 影视娱乐 / 数码科技 / 医疗保健 / 综合杂项
星座情感 / 时尚穿搭 / 婚庆婚礼 / 拍摄记录 / 学习教育 / 化妆美容
居家装修 / 旅行度假 / 亲子育儿 / 个人护理 / 美味佳肴 / 职业发展
宠物天地 / 潮流鞋包 / 日常生活 / 科学探索 / 新闻资讯 / 体育锻炼常用别名映射:
| 用户说 | 映射到 |
|---|---|
| 健身、运动、瑜伽 | 体育锻炼 |
| 美妆、彩妆、护肤 | 化妆美容/个人护理 |
| 美食、探店、烹饪 | 美味佳肴 |
| 旅行、旅游 | 旅行度假 |
| 母婴、育儿 | 亲子育儿 |
| 穿搭、时尚 | 时尚穿搭 |
---
榜单更新规则
| 榜单类型 | 更新时间 | 统计范围 | rankDate 填写规则 | 示例 |
|---|---|---|---|---|
| 日榜 | 每日 19:00 | 前一天 | 填当天日期 | "2026-04-28" |
| 周榜 | 每周一 15:00 | 上周 | 填该周周一日期 | "2026-04-20" |
| 月榜 | 每月2号 上午9:00 | 上月 | 填该月2号 | "2026-04-02" |
日期回退计算
根据当前时间判断取第几期数据:
| 榜单类型 | 当前时间 < 更新时间 | 当前时间 >= 更新时间 |
|---|---|---|
| 日榜 (19:00更新) | offset=2 (前2天) | offset=1 (前1天) |
| 周榜 (周一15:00更新) | offset=2 (前2周) | offset=1 (前1周) |
| 月榜 (2号09:00更新) | offset=2 (前2月) | offset=1 (前1月) |
⚠️ 月榜必须用每月2号,实测用月末最后一天(如 "2026-03-31")返回空数据!
⚠️ 周榜用周一日期,实测用周日日期(如 "2026-04-20" 周日)会返回空数据!
---
常见问题
1. 体育锻炼分类数据为空
部分分类(如体育锻炼)在某些日期可能返回空数据。需要自动回退日期尝试:
2026-04-29: 0条 → 回退
2026-04-28: 50条 ✓2. 互动数据计算
最高互动需要自行计算:
def parse_num(s):
s = str(s).replace('w', '').replace('W', '')
if '.' in s:
return float(s) * 10000
return float(s)
interaction = parse_num(item['newLikes']) + \
parse_num(item['newComments']) + \
parse_num(item['newCollects']) + \
parse_num(item['newShares'])⚠️ 接口返回的 newInteraction 字段值可能为0,不要直接使用!---
Python 调用示例
import json
import os
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from datetime import date, timedelta
url = "https://redfox.hk/story/api/xhsData/query"
# 从环境变量获取 API Key
api_key = os.environ.get("REDFOX_API_KEY", "")
if not api_key:
raise RuntimeError("请设置 REDFOX_API_KEY 环境变量")
# 日榜示例(体育锻炼)
payload_day = {
"dateType": 1,
"rankDate": "2026-04-28",
"type": "体育锻炼",
"source": "小红书指数榜"
}
# 周榜示例
payload_week = {
"dateType": 2,
"rankDate": "2026-04-20", # 上周周一
"type": "综合全部",
"source": "小红书指数榜"
}
# 月榜示例
payload_month = {
"dateType": 3,
"rankDate": "2026-04-02", # 上月2号
"type": "综合全部",
"source": "小红书指数榜"
}
payload_bytes = json.dumps(payload_day).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"X-API-KEY": api_key,
}
req = Request(url, data=payload_bytes, headers=headers, method="POST")
try:
resp = urlopen(req, timeout=15)
data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
print(f"HTTP {e.code}: {e.reason}")
except URLError as e:
print(f"网络请求失败: {e.reason}")
if data["code"] == 2000: # 注意:成功码是 2000
print(f"获取到 {len(data['data'])} 条数据")
for item in data["data"]:
print(item["accountRanking"], item["accountName"],
item["fansCount"], item["likedGrowth"])
else:
print(f"接口返回错误: {data}")小红书账号综合评分规则
评分维度
综合评分基于以下维度计算:
| 维度 | 权重 | 计算方式 |
|---|---|---|
| 总粉丝数 | 20% | ln(x+1)/ln(max+1) × 100 |
| 新增粉丝 | 20% | ln(x+1)/ln(max+1) × 100 |
| 新增点赞 | 15% | ln(x+1)/ln(max+1) × 100 |
| 新增收藏 | 15% | ln(x+1)/ln(max+1) × 100 |
| 新增分享 | 15% | ln(x+1)/ln(max+1) × 100 |
| 新增评论 | 15% | ln(x+1)/ln(max+1) × 100 |
计算公式
对数归一化公式:
score = ln(x + 1) / ln(max + 1) × 100其中:
x为该账号的当前指标值max为本次榜单中该指标的最大值
示例
假设某榜单数据如下:
| 账号 | 新增点赞 | 得分计算 |
|---|---|---|
| A | 10万 | ln(100001)/ln(100001) × 100 = 100 |
| B | 5万 | ln(50001)/ln(100001) × 100 ≈ 85 |
| C | 1万 | ln(10001)/ln(100001) × 100 ≈ 67 |
最终得分
各维度加权求和后,保留整数作为综合评分。
#!/usr/bin/env python3
"""
小红书账号榜单数据抓取脚本 v3
用法:
python fetch_rank.py --period day --date 2026-04-27 --category 美食
python fetch_rank.py --query "最新小红书日榜"
python fetch_rank.py --query "美妆类周榜"
python fetch_rank.py --query "2026年4月月榜"
"""
import argparse
import json
import re
import sys
import os
from datetime import date, datetime, timedelta
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
API_URL = "https://redfox.hk/story/api/xhsData/query"
def _get_api_key() -> str:
"""从环境变量获取 API Key,返回格式 ak_xxxxxxxx。"""
return os.environ.get("REDFOX_API_KEY", "").strip()
def _http_post(url: str, payload: dict, timeout: int = 15) -> dict:
"""
使用原生 urllib 发送 HTTPS 请求(正常验证 SSL 证书)。
自动注入 X-API-KEY 鉴权头。
"""
api_key = _get_api_key()
if not api_key:
print("[ERROR] 未设置 REDFOX_API_KEY 环境变量", file=sys.stderr)
print("[提示] 请访问 https://redfox.hk/ 注册获取 API Key", file=sys.stderr)
print("[提示] 设置方式:export REDFOX_API_KEY=<你的apikey>", file=sys.stderr)
sys.exit(4)
payload_bytes = json.dumps(payload).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X-API-KEY": api_key,
}
req = Request(url, data=payload_bytes, headers=headers, method="POST")
try:
resp = urlopen(req, timeout=timeout)
data = resp.read().decode("utf-8")
return json.loads(data)
except json.JSONDecodeError:
print("[ERROR] 返回数据格式异常(非 JSON)", file=sys.stderr)
sys.exit(2)
except HTTPError as e:
print(f"[ERROR] HTTP {e.code}: {e.reason}", file=sys.stderr)
sys.exit(2)
except URLError as e:
print(f"[ERROR] 网络请求失败: {e.reason}", file=sys.stderr)
sys.exit(2)
except TimeoutError:
print("[ERROR] 请求超时,请检查网络后重试", file=sys.stderr)
sys.exit(2)
# ─────────────────────────────────────────────────────────
# 赛道映射:关键词 → 接口 type 参数
# ─────────────────────────────────────────────────────────
CATEGORY_MAP = {
# 精确
"综合全部": "综合全部",
"出行代步": "出行代步",
"休闲爱好": "休闲爱好",
"影视娱乐": "影视娱乐",
"数码科技": "数码科技",
"医疗保健": "医疗保健",
"综合杂项": "综合杂项",
"星座情感": "星座情感",
"时尚穿搭": "时尚穿搭",
"婚庆婚礼": "婚庆婚礼",
"拍摄记录": "拍摄记录",
"学习教育": "学习教育",
"化妆美容": "化妆美容",
"居家装修": "居家装修",
"旅行度假": "旅行度假",
"亲子育儿": "亲子育儿",
"个人护理": "个人护理",
"美味佳肴": "美味佳肴",
"职业发展": "职业发展",
"宠物天地": "宠物天地",
"潮流鞋包": "潮流鞋包",
"日常生活": "日常生活",
"科学探索": "科学探索",
"新闻资讯": "新闻资讯",
"体育锻炼": "体育锻炼",
# 别名 / 模糊
"美妆": "化妆美容", "彩妆": "化妆美容", "护肤": "个人护理",
"美容": "个人护理", "护肤美妆": "化妆美容",
"科技": "数码科技", "互联网": "数码科技", "ai": "数码科技", "人工智能": "数码科技",
"健康": "医疗保健", "养生": "医疗保健", "医疗": "医疗保健",
"美食": "美味佳肴", "探店": "美味佳肴", "烹饪": "美味佳肴", "烘焙": "美味佳肴",
"旅行": "旅行度假", "旅游": "旅行度假",
"出行": "出行代步", "户外": "休闲爱好",
"穿搭": "时尚穿搭", "时尚": "时尚穿搭",
"母婴": "亲子育儿", "育儿": "亲子育儿", "亲子": "亲子育儿",
"教育": "学习教育", "学习": "学习教育",
"宠物": "宠物天地", "猫": "宠物天地", "狗": "宠物天地",
"健身": "体育锻炼", "运动": "体育锻炼", "瑜伽": "体育锻炼",
"装修": "居家装修", "家居": "居家装修", "家装": "居家装修",
"情感": "星座情感", "星座": "星座情感",
"娱乐": "影视娱乐", "影视": "影视娱乐", "视频": "影视娱乐",
"vlog": "拍摄记录", "摄影": "拍摄记录", "拍照": "拍摄记录",
"职场": "职业发展", "职场发展": "职业发展", "招聘": "职业发展",
"婚礼": "婚庆婚礼", "结婚": "婚庆婚礼",
"鞋包": "潮流鞋包", "包包": "潮流鞋包",
"新闻": "新闻资讯", "资讯": "新闻资讯",
"科普": "科学探索", "科学": "科学探索",
"综合": "综合全部", "全品类": "综合全部", "全部": "综合全部",
"日常": "日常生活",
}
# 接口 type 精确值(用于判断是否为模糊匹配)
EXACT_CATEGORIES = {
"综合全部", "出行代步", "休闲爱好", "影视娱乐", "数码科技",
"医疗保健", "综合杂项", "星座情感", "时尚穿搭", "婚庆婚礼",
"拍摄记录", "学习教育", "化妆美容", "居家装修", "旅行度假",
"亲子育儿", "个人护理", "美味佳肴", "职业发展", "宠物天地",
"潮流鞋包", "日常生活", "科学探索", "新闻资讯", "体育锻炼",
}
PERIOD_MAP = {"day": 1, "week": 2, "month": 3}
PERIOD_LABEL_MAP = {"day": "日榜", "week": "周榜", "month": "月榜"}
PERIOD_LABELS = {"day": "日榜", "week": "周榜", "month": "月榜"}
# 短字符别名(精确或词边界匹配)
SHORT_ALIASES = {"day": "日", "week": "周", "month": "月"}
# 长字符别名(子串匹配即可)
LONG_ALIASES = {
"day": ["day", "daily", "日榜", "日排名", "日间", "今日", "昨日"],
"week": ["week", "weekly", "周榜", "周排名", "周间", "本周", "本周涨粉", "上周", "本周涨"],
"month": ["month", "monthly", "月榜", "月排名", "月间", "本月", "上月"],
}
def _parse_period_keyword(text: str) -> str | None:
# 先屏蔽完整日期模式,避免"5月10日"中的"月"被匹配为月榜
masked = re.sub(r"\d{4}[年\-\/]\d{1,2}[月\-\/]\d{1,2}", "____", text) # 2026年5月10号
masked = re.sub(r"\d{1,2}月\d{1,2}(日|号)?", "____", masked) # 5月10日/5月10号
masked = re.sub(r"\d{1,2}[日号](?!榜)", "____", masked) # 10日(排除日榜)
# 优先:长字符串子串匹配(不易误匹配)
for p, aliases in LONG_ALIASES.items():
for a in aliases:
if a in masked:
return p
# 其次:短字符精确或词边界匹配
for p, char in SHORT_ALIASES.items():
for i, ch in enumerate(masked):
if ch == char:
before = masked[i - 1] if i > 0 else " "
after = masked[i + 1] if i < len(masked) - 1 else " "
# 如果前后是字母/数字/空格/标点,或正好是边界,则匹配
if not (("\u4e00" <= before <= "\u9fff") or ("\u4e00" <= after <= "\u9fff")):
return p
return None
UPDATE_RULES = {
1: {"label": "日榜", "update_time": "每日19:00", "window_days": 7},
2: {"label": "周榜", "update_time": "每周一15:00", "window_days": 21},
3: {"label": "月榜", "update_time": "每月2号9:00", "window_days": 90},
}
def _get_latest_date(period: str, offset: int = 1) -> date:
"""
获取指定周期的目标日期。
period: day / week / month
offset: 往前回溯的期数,默认 1(上一期)
周榜:返回指定期数的周一
月榜:返回指定期数的2号
日榜:返回指定期数的前一天
"""
today = date.today()
if period == "day":
return today - timedelta(days=offset)
elif period == "week":
# 周榜:取 offset 期前的周一
# 周一=0,周二=1,...,周日=6
# 本周一:today - weekday() 天
days_since_monday = today.weekday()
this_monday = today - timedelta(days=days_since_monday)
# offset=1 → 上周一 = 本周一 - 7天
# offset=2 → 上上周一 = 本周一 - 14天
return this_monday - timedelta(weeks=offset)
elif period == "month":
# 月榜:取 offset 期前的2号
month_offset = offset - 1
year = today.year
month = today.month - month_offset
while month <= 0:
month += 12
year -= 1
return date(year, month, 1)
return today - timedelta(days=offset)
def _is_within_window(target_date: date, period: str) -> bool:
today = date.today()
diff = (today - target_date).days
window = UPDATE_RULES[PERIOD_MAP[period]]["window_days"]
return 1 <= diff <= window
def _is_data_updated(period: str) -> bool:
"""
判断当前时间是否已更新最新一期的数据。
返回 True 表示已更新(可取上一期),False 表示未更新(需取上上期)。
更新规则:
- 日榜:每日 19:00 更新
- 周榜:每周一 15:00 后更新
- 月榜:每月 2号 9:00 更新
"""
now = datetime.now()
current_time = now.time()
if period == "day":
# 每日 19:00 更新
return current_time.hour >= 19
elif period == "week":
# 每周一 15:00 后更新
if now.weekday() == 0: # 周一
return current_time.hour >= 15
else:
# 不是周一,如果已过周一15:00则已更新
return True
elif period == "month":
# 每月 2号 9:00 更新
if now.day == 2:
return current_time.hour >= 9
elif now.day == 1:
return False # 1号,尚未到2号9点
else:
# 已过2号9:00,已更新
return True
return True
def _parse_date_from_text(text: str, explicit_period: str | None = None) -> tuple[date | None, str | None]:
"""
从文本中解析日期。
若 explicit_period 已明确(如用户说了"周榜"),则不通过"最新/今日"等
模糊关键词来推断 period,避免覆盖已识别出的明确周期。
"最新"关键词会根据更新时间判断:
- 已更新 → 取上一期
- 未更新 → 取上上期
"""
today = date.today()
# YYYY-MM-DD / YYYY年MM月DD日
m = re.search(r"(\d{4})[年\-\/](\d{1,2})[月\-\/](\d{1,2})", text)
if m:
try:
return date(int(m.group(1)), int(m.group(2)), int(m.group(3))), None
except Exception:
pass
# MM月DD日 / MM-DD 格式(不带年份)
m2 = re.search(r"(\d{1,2})月(\d{1,2})(日|号)?", text)
if m2:
try:
month = int(m2.group(1))
day = int(m2.group(2))
# 假设是当年
year = date.today().year
return date(year, month, day), None
except Exception:
pass
# 纯日期关键词(只取日期,不推断 period)
for kw, delta, require_period in [
("最新", None, True), # delta=None 表示需要根据更新时间动态判断
("今日", 1, False), ("今天", 1, False), ("昨日", 1, False),
("本周", 0, False), ("这周", 0, False), ("本月", 0, False), ("这个月", 0, False),
]:
if kw in text:
base_period = explicit_period or "day"
# 动态计算 delta
if delta is None:
# "最新":根据更新时间判断
if _is_data_updated(base_period):
delta = 1 # 已更新,取上一期
else:
delta = 2 # 未更新,取上上期
d = _get_latest_date(base_period, offset=delta)
return d, explicit_period # 仅返回日期,period 以 explicit_period 为准
return None, None
def match_category(keyword: str) -> tuple[str, bool]:
kw = keyword.strip()
if not kw:
return "综合全部", False
if kw in CATEGORY_MAP:
return CATEGORY_MAP[kw], kw in EXACT_CATEGORIES
lower_kw = kw.lower()
for k, v in CATEGORY_MAP.items():
if k.lower() == lower_kw:
return v, k in EXACT_CATEGORIES
for k, v in CATEGORY_MAP.items():
if k in kw or kw in k:
return v, True
return "综合全部", True
# ─────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────
# 综合评分计算
# ─────────────────────────────────────────────────────────
def _parse_num(val) -> float:
"""将字符串或数字转为 float,失败返回 0"""
if val is None or val == "-" or val == "":
return 0
if isinstance(val, (int, float)):
return float(val)
s = str(val).strip()
if not s:
return 0
try:
if "w" in s.lower() or "万" in s:
return float(s.lower().replace("w", "").replace("万", "")) * 10000
return float(s)
except Exception:
return 0
def _normalize_by_max(values: list, base: float = 100) -> list:
"""归一化:除以最大值,乘以基础分"""
max_val = max(values) if values else 0
if max_val == 0:
return [0.0] * len(values)
return [v / max_val * base for v in values]
def calculate_scores_batch(items: list) -> list:
"""
批量计算评分并返回归一化后的分数
评分规则(满分100):使用对数归一化 ln(x)/ln(max)
- 总粉丝数:20%
- 新增粉丝:20%
- 新增点赞:15%
- 新增收藏:15%
- 新增分享:15%
- 新增评论:15%
"""
import math
# 提取所有数据
followers_list = [_parse_num(i.get("followers") or i.get("fansCount") or i.get("fans")) for i in items]
fans_list = [_parse_num(i.get("newFans") or i.get("fansGrowth")) for i in items]
likes_list = [_parse_num(i.get("newLikes") or i.get("likedGrowth")) for i in items]
collects_list = [_parse_num(i.get("newCollects") or i.get("collectedGrowth")) for i in items]
shares_list = [_parse_num(i.get("newShares") or i.get("sharedGrowth")) for i in items]
comments_list = [_parse_num(i.get("newComments") or i.get("commentsGrowth")) for i in items]
# 获取最大值
max_followers = max(followers_list) if followers_list else 1
max_fans = max(fans_list) if fans_list else 1
max_likes = max(likes_list) if likes_list else 1
max_collects = max(collects_list) if collects_list else 1
max_shares = max(shares_list) if shares_list else 1
max_comments = max(comments_list) if comments_list else 1
results = []
for item in items:
followers = _parse_num(item.get("followers") or item.get("fansCount") or item.get("fans"))
fans_growth = _parse_num(item.get("newFans") or item.get("fansGrowth"))
likes_growth = _parse_num(item.get("newLikes") or item.get("likedGrowth"))
collects_growth = _parse_num(item.get("newCollects") or item.get("collectedGrowth"))
shares_growth = _parse_num(item.get("newShares") or item.get("sharedGrowth"))
comments_growth = _parse_num(item.get("newComments") or item.get("commentsGrowth"))
# 对数归一化:ln(x+1)/ln(max+1)
def log_norm(val, max_val):
if val <= 0 or max_val <= 1:
return 0
return math.log(val + 1) / math.log(max_val + 1) * 100
norm_followers = log_norm(followers, max_followers)
norm_fans = log_norm(fans_growth, max_fans)
norm_likes = log_norm(likes_growth, max_likes)
norm_collects = log_norm(collects_growth, max_collects)
norm_shares = log_norm(shares_growth, max_shares)
norm_comments = log_norm(comments_growth, max_comments)
# 加权求和
score = (
norm_followers * 0.20 +
norm_fans * 0.20 +
norm_likes * 0.15 +
norm_collects * 0.15 +
norm_shares * 0.15 +
norm_comments * 0.15
)
item["comprehensiveScore"] = int(score) # 保留整数
return items
# ─────────────────────────────────────────────────────────
def fetch(period: str, rank_date: str, category: str, is_latest: bool = False) -> tuple[list[dict], str, str]:
"""
获取榜单数据
is_latest: 是否查询"最新",如果是则当天无数据时告知更新时间并回退前一天
返回: (数据列表, 实际查询的日期, 提示信息)
"""
from datetime import datetime, timedelta
# 更新时间提示
update_time_hint = {
"day": "每日19:00",
"week": "每周一15:00",
"month": "每月2号9:00",
}
current_date = rank_date
original_date = rank_date
fallback_hint = ""
payload = {
"dateType": PERIOD_MAP[period],
"rankDate": current_date,
"type": category if category else "综合全部",
"source": "小红书最夯账号-GitHub",
}
result = _http_post(API_URL, payload, timeout=15)
if result.get("code") == 2000:
data = result.get("data", [])
if data:
return data, current_date, ""
# 当天无数据
if is_latest:
# 查询最新数据但当天无数据,告知更新时间并回退前一天
ut = update_time_hint.get(period, "")
fallback_hint = f"{current_date} 暂无数据({PERIOD_LABEL_MAP.get(period, '日榜')}{ut}更新),已为您查询前一天的数据"
# 回退一天
try:
d = datetime.strptime(current_date, "%Y-%m-%d")
d -= timedelta(days=1)
current_date = d.strftime("%Y-%m-%d")
except ValueError:
print("[ERROR] 日期格式错误", file=sys.stderr)
sys.exit(3)
payload["rankDate"] = current_date
result = _http_post(API_URL, payload, timeout=15)
if result.get("code") == 2000:
data = result.get("data", [])
if data:
return data, current_date, fallback_hint
# 前一天也无数据,继续回退最多7天
for i in range(2, 8):
try:
d = datetime.strptime(current_date, "%Y-%m-%d")
d -= timedelta(days=1)
current_date = d.strftime("%Y-%m-%d")
except ValueError:
break
payload["rankDate"] = current_date
result = _http_post(API_URL, payload, timeout=15)
if result.get("code") == 2000:
data = result.get("data", [])
if data:
fallback_hint = f"{original_date} 暂无数据,已为您查询 {current_date} 的数据"
return data, current_date, fallback_hint
print(f"[ERROR] 近7天均无数据", file=sys.stderr)
sys.exit(3)
else:
# 用户指定具体日期但无数据,告知更新时间并自动回退前一天
ut = update_time_hint.get(period, "")
fallback_hint = f"{current_date} 暂无数据({PERIOD_LABEL_MAP.get(period, '日榜')}{ut}更新),已为您查询前一天的数据"
try:
d = datetime.strptime(current_date, "%Y-%m-%d")
d -= timedelta(days=1)
current_date = d.strftime("%Y-%m-%d")
except ValueError:
print("[ERROR] 日期格式错误", file=sys.stderr)
sys.exit(3)
payload["rankDate"] = current_date
result = _http_post(API_URL, payload, timeout=15)
if result.get("code") == 2000:
data = result.get("data", [])
if data:
return data, current_date, fallback_hint
print(f"[ERROR] {original_date} 及前一天均暂无数据", file=sys.stderr)
sys.exit(3)
return [], current_date, ""
def _fmt_num(val) -> str:
"""将数字格式化为易读形式,None / 0 / '-' 统一显示 '-'"""
if val is None or val == "-":
return "-"
try:
n = int(val)
except (ValueError, TypeError):
return str(val)
if n == 0:
return "-"
if n >= 100_000_000:
return f"{n/100_000_000:.1f}亿"
if n >= 10_000:
return f"{n/10_000:.1f}w"
return str(n)
def build_markdown_table(items: list, period_label: str, date_str: str,
cat_display: str, fetch_time: str, total: int,
fuzzy_flag: bool = False) -> str:
update_time_map = {1: "每日19:00", 2: "每周一15:00", 3: "每月2号9:00"}
period_key = {"日榜": "day", "周榜": "week", "月榜": "month"}.get(period_label, "day")
ut = update_time_map.get(PERIOD_MAP.get(period_key, 1), "")
lines = [
f"💡 榜单说明:{period_label}{ut}更新。",
"📐 排名算法:综合评分根据达人在小红书的 **总粉丝数**、周期内的 **粉丝增量**、"
"**点赞增量**、**收藏增量**、**分享增量** 以及 **评论增量** 加权计算所得(满分100)。",
]
cat_suffix = f"({cat_display})" if cat_display != "全品类" else ""
lines.append(f"📊 {date_str} 小红书{cat_display}{cat_suffix}最夯账号({period_label})")
lines.append("")
# 表头(新增综合评分列)
lines.append("排名\t账号名\t综合评分\t总粉丝数\t新增笔记数\t新增粉丝\t新增点赞\t新增评论\t新增收藏\t新增分享")
lines.append("----\t------\t----\t----------\t--------\t--------\t--------\t--------\t--------\t--------")
is_all_category = (cat_display == "全品类" or cat_display == "综合全部")
for item in items:
account_name = item.get('accountName', '')
account_link = item.get('accountLink', item.get('profileUrl', ''))
track = (item.get('category') or cat_display)
# 全品类时账号名后加"·赛道"
if is_all_category and track:
account_display = f"[{account_name}·{track}]({account_link})" if account_link else f"{account_name}·{track}"
else:
account_display = f"[{account_name}]({account_link})" if account_link else account_name
followers = _fmt_num(item.get('fansCount') or item.get('followers') or item.get('totalFollowers'))
new_fans = _fmt_num(item.get('fansGrowth') or item.get('newFans') or item.get('newFollowers'))
new_likes = _fmt_num(item.get('likedGrowth') or item.get('newLikes') or item.get('newLikeCount'))
new_comments= _fmt_num(item.get('commentsGrowth') or item.get('newComments') or item.get('newCommentCount'))
new_collects= _fmt_num(item.get('collectedGrowth') or item.get('newCollects') or item.get('newCollectCount'))
new_shares = _fmt_num(item.get('sharedGrowth') or item.get('newShares') or item.get('newShareCount'))
new_notes = item.get('newNoteCount') or '-'
rank = item.get('accountRanking') or item.get('rank', '-')
score = item.get('comprehensiveScore', 0)
if isinstance(score, (int, float)) and score > 0:
score = int(score)
else:
score = '-'
lines.append(
f"{rank}\t{account_display}\t{score}\t{followers}\t"
f"{new_notes}\t{new_fans}\t{new_likes}\t{new_comments}\t{new_collects}\t{new_shares}"
)
shown = len(items)
lines.extend([
"",
"⚡ 更多操作",
"• 点击下方下载HTML报告文件,可在浏览器中打开查看,支持一键导出PDF/高清图片",
f"• 本次榜单完整共{total}条数据,是否需要查看剩余{max(0,total-shown)}条?",
"",
"📬 订阅服务",
"1️⃣ 是否需要订阅每日/周/月的小红书账号最新排名,订阅后定时推送给您?",
"2️⃣ 是否需要订阅具体赛道的账号表现?我们支持:综合全部、出行代步、休闲爱好、影视娱乐、数码科技、医疗保健、综合杂项、星座情感、时尚穿搭、婚庆婚礼、拍摄记录、学习教育、化妆美容、居家装修、旅行度假、亲子育儿、个人护理、美味佳肴、职业发展、宠物天地、潮流鞋包、日常生活、科学探索、新闻资讯、体育锻炼",
])
return "\n".join(lines)
def to_normalized_json(items: list, period: str, date_str: str,
category: str) -> dict:
fetch_time = items[0].get("dataFetchTime", "") if items else ""
def parse_inter(s: str) -> int:
s = s.strip()
if not s:
return 0
if "w" in s.lower():
try:
return int(float(s.lower().replace("w", "")) * 10000)
except Exception:
return 0
try:
return int(s)
except Exception:
return 0
# 批量计算评分并按评分降序排序
scored_items = calculate_scores_batch(items)
normalized_list = []
for item in scored_items:
score = item.get("comprehensiveScore", 0)
normalized_list.append({
"rank": item.get("accountRanking") or item.get("rank"),
"accountName": item.get("accountName", ""),
# 新接口直接返回 category 字段
"category": item.get("category") or category or "综合全部",
"comprehensiveScore": score,
# 粉丝总量
"followers": item.get("fansCount") or item.get("followers") or None,
# 新增各项指标(新接口字段:字符串格式如 "6.68w")
"newNoteCount": item.get("newNoteCount") or 0,
"newFans": item.get("fansGrowth") or item.get("newFans") or None,
"newLikes": item.get("likedGrowth") or item.get("newLikes") or None,
"newComments": item.get("commentsGrowth") or item.get("newComments") or None,
"newCollects": item.get("collectedGrowth") or item.get("newCollects") or None,
"newShares": item.get("sharedGrowth") or item.get("newShares") or None,
# 互动合计(旧接口兼容保留)
"newInteraction": parse_inter(item.get("newInteractionCount", "0")),
"profileUrl": item.get("accountLink") or item.get("profileUrl", ""),
})
# 按综合评分降序排序
normalized_list.sort(key=lambda x: x.get("comprehensiveScore", 0), reverse=True)
# 更新排序后的排名
for i, item in enumerate(normalized_list):
item["rank"] = i + 1
return {
"period": period,
"date": date_str,
"category": category,
"fetchTime": fetch_time,
"total": len(normalized_list),
"list": normalized_list,
}
def parse_natural_query(query: str) -> dict:
text = query.strip()
result = {
"period": "day",
"date": str(_get_latest_date("day")),
"category": "综合全部",
"category_fuzzy": False,
"warning": "",
"date_fallback": False,
"is_latest": False, # 是否询问最新数据
}
# 判断是否询问最新(没有明确指定具体日期数字)
# "最新"、"今天"不算明确日期,应该走 is_latest + 自动回退
has_explicit_date = bool(
re.search(r"\d{4}[年\-\/]\d{1,2}[月\-\/]\d{1,2}", text) or # 2026-05-10
re.search(r"\d{1,2}月\d{1,2}(日|号)?", text) or # 5月10号
re.search(r"\d{1,2}[日号](?!榜)", text) # 10日/10号(排除"日榜")
)
result["is_latest"] = not has_explicit_date
# 1. 解析周期(_parse_period_keyword 已内置日期屏蔽)
period = _parse_period_keyword(text)
# 2. 解析日期
found_date, _ = _parse_date_from_text(text, explicit_period=period)
# 3. 确定周期和日期
if found_date:
# 有明确日期时,如果也匹配到了周期关键词则用周期,否则默认日榜
if period and period != "day":
result["period"] = period
else:
result["period"] = "day"
result["date"] = str(found_date)
else:
# 无明确日期,使用周期关键词(默认日榜)
result["period"] = period if period else "day"
# is_latest=True 时,日期由 fetch 函数自动回退
# 3. 回溯范围校验
target_date = date.fromisoformat(result["date"])
if not _is_within_window(target_date, result["period"]):
window = UPDATE_RULES[PERIOD_MAP[result["period"]]]["window_days"]
unit = {"day": "天", "week": "周", "month": "月"}[result["period"]]
best_date = _get_latest_date(result["period"])
result["warning"] = (
f"⚠️ 抱歉🙏,目前小红书榜单最多支持回溯「近{window}{unit}」,"
f"已自动切换至最近可用数据。"
)
result["date"] = str(best_date)
result["date_fallback"] = True
# 4. 赛道匹配(去除周期/日期关键词后)
exclude = r"[\d年月日周榜周排名本月本月度今日最近最新"
exclude += r"小红书榜单排名账号排行榜最夯给我查询看看想要要看]"
kw = re.sub(exclude, "", text)
matched_type, fuzzy = match_category(kw)
result["category"] = matched_type
result["category_fuzzy"] = fuzzy
return result
# ─────────────────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="小红书账号榜单抓取 v3(支持自然语言)")
parser.add_argument("--query", "-q", type=str, default="",
help="自然语言查询,如:最新美食周榜 / 2026年4月美妆日榜")
parser.add_argument("--period", choices=["day", "week", "month"], default=None)
parser.add_argument("--date", default="", help="目标日期 YYYY-MM-DD")
parser.add_argument("--category", default="", help="赛道类型")
parser.add_argument("--limit", type=int, default=20)
parser.add_argument("--output", default="", help="JSON 输出文件路径")
parser.add_argument("--format", choices=["markdown", "json"], default="markdown")
parser.add_argument("--html", action="store_true", help="生成 HTML 报告文件")
args = parser.parse_args()
# 自然语言模式
if args.query:
parsed = parse_natural_query(args.query)
period = parsed["period"]
rank_date = parsed["date"]
category = parsed["category"]
category_fuzzy = parsed["category_fuzzy"]
if parsed["warning"]:
print(f"\n{parsed['warning']}\n", file=sys.stderr)
if parsed["date_fallback"]:
print(f"[WARN] 已回退至最近可用日期:{rank_date}", file=sys.stderr)
print(f"[INFO] 解析结果 → 周期={period} 日期={rank_date} 赛道={category} 最新={parsed['is_latest']}", file=sys.stderr)
else:
period = args.period or "day"
rank_date = args.date if args.date else str(_get_latest_date(period))
category = args.category if args.category else "综合全部"
category_fuzzy = False
parsed = {"is_latest": args.date == ""} # 没有指定日期则为最新查询
# 回溯校验
target_date = date.fromisoformat(rank_date)
if not _is_within_window(target_date, period):
window = UPDATE_RULES[PERIOD_MAP[period]]["window_days"]
unit = {"day": "天", "week": "周", "month": "月"}[period]
best = _get_latest_date(period)
print(f"\n⚠️ 抱歉🙏,榜单最多回溯「近{window}{unit}」,已切换至 {best}\n", file=sys.stderr)
rank_date = str(best)
limit = min(args.limit, 50)
# 判断是否查询最新数据(用户未指定具体日期)
is_latest = parsed.get("is_latest", False)
items, actual_date, fallback_info = fetch(period, rank_date, category, is_latest=is_latest)
# 如果实际查询的日期与请求的日期不同,更新 rank_date
if actual_date != rank_date:
rank_date = actual_date
# 如果有回退提示信息,输出给用户
if fallback_info:
print(fallback_info)
total = len(items)
fetch_time = items[0].get("dataFetchTime", rank_date) if items else rank_date
period_label = PERIOD_LABELS.get(period, "日榜")
CAT_DISPLAY = {
"综合全部": "全品类", "化妆美容": "美妆类", "个人护理": "个护类",
"美味佳肴": "美食类", "旅行度假": "旅行类", "数码科技": "科技类",
"医疗保健": "健康类", "亲子育儿": "亲子类", "体育锻炼": "运动类",
"学习教育": "教育类", "宠物天地": "宠物类", "时尚穿搭": "穿搭类",
"居家装修": "家居类", "职业发展": "职场类", "影视娱乐": "娱乐类",
"星座情感": "情感类", "潮流鞋包": "鞋包类", "休闲爱好": "爱好类",
"科学探索": "科普类", "新闻资讯": "资讯类", "出行代步": "出行类",
"拍摄记录": "拍摄类", "婚庆婚礼": "婚礼类", "综合杂项": "综合类",
"日常生活": "日常类",
}
cat_display = CAT_DISPLAY.get(category, category + "类")
# 先在全部数据上计算评分并排序,确保评分一致性
normalized_all = to_normalized_json(items, period, rank_date, category)
# 保存 JSON 时使用 limit 截取已排序的数据
if args.output:
saved_items = items[:args.limit]
normalized = to_normalized_json(saved_items, period, rank_date, category)
normalized["total"] = len(saved_items)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(normalized, f, ensure_ascii=False, indent=2)
print(f"[INFO] 已保存:{args.output}", file=sys.stderr)
if args.format == "json":
print(json.dumps(items, ensure_ascii=False, indent=2))
else:
# Markdown 输出时使用截取后的已评分数据
display = normalized_all["list"][:limit]
print(build_markdown_table(display, period_label, rank_date,
cat_display, fetch_time, total, category_fuzzy))
# 生成 HTML 报告文件
if args.html:
from generate_report import generate_html
# 准备数据
html_data = {
"list": normalized_all["list"][:limit],
"total": min(total, limit),
"period": period,
"date": rank_date,
"category": category,
"fetchTime": fetch_time,
}
# 生成文件名:赛道+周期+日期_时间戳
date_str = rank_date.replace("-", "")
timestamp = datetime.now().strftime("%H%M%S")
html_filename = f"小红书{cat_display}{period_label}_{date_str}_{timestamp}.html"
html_path = Path.cwd() / html_filename
generate_html(html_data, str(html_path))
print(f"\n📄 HTML报告已生成:{html_path}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
小红书榜单 HTML 报告生成脚本
用法:
python generate_report.py --data result.json --output report.html
python generate_report.py --data result.json # 自动命名输出文件
"""
import argparse
import json
import sys
import html as html_utils
from datetime import datetime
from pathlib import Path
# ─────────────────────────────────────────────────────────
# 赛道识别:基于账号名 + 主页链接,识别更精准的细分赛道
# ─────────────────────────────────────────────────────────
# 细分赛道识别规则(按优先级排序)
CATEGORY_INFER_RULES = [
# 游戏类(最高优先级)
{"keywords": ["王者荣耀", "第五人格", "崩坏", "蛋仔", "原神", "鸣潮", "阴阳师", "明日方舟", "光遇", "恋与深空"], "category": "游戏"},
{"keywords": ["吃鸡", "和平精英", "PUBG", "英雄联盟", "LOL", "无畏契约", "Valorant", "CSGO", "DOTA"], "category": "游戏"},
{"keywords": ["迷你世界", "我的世界", "MC", "Minecraft"], "category": "游戏"},
{"keywords": ["超自然行动组"], "category": "游戏"},
# 品牌/官方类
{"keywords": ["official", "工作室"], "category": "官方账号"},
{"keywords": ["爱奇艺", "优酷", "腾讯视频", "芒果TV", "Bilibili", "B站"], "category": "视频平台"},
{"keywords": ["微博", "新浪", "凤凰网", "澎湃", "界面新闻"], "category": "新闻媒体"},
{"keywords": ["茶百道", "喜茶", "奈雪", "一点点", "蜜雪冰城", "霸王茶姬"], "category": "奶茶饮品"},
{"keywords": ["德施曼", "凯迪仕", "飞利浦智能锁"], "category": "智能家居"},
{"keywords": ["天猫", "京东", "淘宝", "拼多多"], "category": "电商平台"},
# 明星/娱乐类
{"keywords": ["赵露思", "白鹿", "虞书欣", "张凌赫", "田曦薇", "万妮达", "李维嘉"], "category": "明星"},
{"keywords": ["煎饼果仔"], "category": "短剧"},
{"keywords": ["小胡同学", "神仙藤井树"], "category": "美妆博主"},
{"keywords": ["papi酱"], "category": "搞笑博主"},
# 美食类细分
{"keywords": ["刘雨鑫", "茄猫", "小镇上的猪精", "阿晨吃饱了"], "category": "美食博主"},
{"keywords": ["美食", "吃货"], "category": "美食博主"},
{"keywords": ["烘焙", "蛋糕", "面包", "甜品"], "category": "烘焙甜点"},
{"keywords": ["探店", "奶茶", "咖啡"], "category": "探店美食"},
# 旅行/户外类
{"keywords": ["程前朋友圈"], "category": "商业观察"},
{"keywords": ["柯子又胖了", "旅行", "旅游", "自驾", "露营"], "category": "旅行博主"},
{"keywords": ["Linksphotograph", "行缘", "旅行摄影"], "category": "旅行摄影"},
# 宠物类
{"keywords": ["扣肉有脾气", "宠物", "猫", "狗", "萌宠", "铲屎"], "category": "宠物博主"},
# 时尚/穿搭类
{"keywords": ["穿搭", "OOTD", "衣橱"], "category": "穿搭博主"},
{"keywords": ["康康和爷爷"], "category": "时尚博主"},
{"keywords": ["白昼小熊"], "category": "潮流博主"},
# 健身/运动类
{"keywords": ["帕梅拉", "欧阳春晓"], "category": "健身博主"},
{"keywords": ["健身", "瑜伽", "减脂", "增肌", "运动"], "category": "健身运动"},
# 学习/知识类
{"keywords": ["周小闹", "Prof.Alan"], "category": "知识博主"},
{"keywords": ["教育", "学习", "干货", "职场"], "category": "知识教育"},
# 科技/测评类
{"keywords": ["小狮日记", "数码", "手机", "电脑", "测评", "评测", "搞机", "极客", "科技"], "category": "科技数码"},
{"keywords": ["AI", "人工智能", "ChatGPT", "GPT"], "category": "AI科技"},
# 亲子/母婴类
{"keywords": ["一只静猪"], "category": "亲子博主"},
{"keywords": ["母婴", "育儿", "宝宝", "辣妈", "萌娃"], "category": "母婴育儿"},
# 家居/装修类
{"keywords": ["家居", "装修", "软装", "设计", "收纳"], "category": "家居博主"},
# 情感/心理类
{"keywords": ["星座", "情感", "恋爱", "心理", "塔罗", "MBTI"], "category": "情感博主"},
# 日常生活/Vlog
{"keywords": ["日记", "vlog", "plog", "日常", "记录", "生活"], "category": "日常Vlog"},
# 科学/探索类
{"keywords": ["亿点点不一样", "科普", "科学", "探索", "实验"], "category": "科学探索"},
]
# 兜底关键词
FALLBACK_KEYWORDS = {
"新闻媒体": ["吃瓜", "资讯", "热点", "日报"],
"影视娱乐": ["影视", "娱乐", "综艺", "剧集", "电影", "演员"],
"休闲爱好": ["爱好", "手工", "DIY", "绘画", "乐器"],
"科学探索": ["天文", "地理", "宇宙", "太空"],
}
def infer_category(account_name: str, profile_url: str = "") -> str:
"""
根据账号名和主页链接推断赛道分类。
优先级:精确账号匹配 > 分类规则 > 兜底
"""
combined = (account_name + " " + profile_url).lower()
# 1. 精确账号名匹配(最高优先级)
exact_account_map = {
"王者荣耀": "游戏",
"第五人格": "游戏",
"崩坏:星穹铁道": "游戏",
"网易蛋仔派对": "游戏",
"超自然行动组": "游戏",
"恋与深空": "游戏",
"茶百道ChaPanda": "奶茶饮品",
"德施曼": "智能家居",
"爱奇艺": "视频平台",
"亿点点不一样": "科学探索",
"朱铁雄": "短剧",
"一只静猪": "亲子博主",
"扣肉有脾气": "宠物博主",
"王冰汝": "新闻媒体",
"神仙藤井树": "美妆博主",
"小胡同学呀": "美妆博主",
"康康和爷爷": "时尚博主",
"帕梅拉Pamela Reif": "健身博主",
"煎饼果仔(张问初)": "短剧",
"Prof.Alan Macfarlane": "知识博主",
"周小闹": "知识博主",
"刘雨鑫JASON": "美食博主",
"茄猫的罐头": "美食博主",
"程前朋友圈": "商业观察",
"Linksphotograph": "旅行摄影",
"李蠕蠕": "搞笑博主",
"吃瓜吗喽": "新闻媒体",
}
for exact_name, cat in exact_account_map.items():
if exact_name.lower() in combined:
return cat
# 2. 规则遍历匹配
for rule in CATEGORY_INFER_RULES:
for kw in rule["keywords"]:
if kw.lower() in combined:
return rule["category"]
# 3. 兜底关键词
for category, keywords in FALLBACK_KEYWORDS.items():
for kw in keywords:
if kw.lower() in combined:
return category
return "日常Vlog"
def parse_num(val) -> int:
"""解析数字字符串,如 '24.64w' -> 246400, '6919' -> 6919"""
if val is None or val == "-":
return 0
s = str(val).replace("w", "").replace("W", "").replace("+", "")
try:
if "." in s:
return int(float(s) * 10000)
return int(float(s))
except (ValueError, TypeError):
return 0
def format_interaction(num: int) -> str:
if num >= 100_000:
return f"{num // 10_000}w+"
elif num >= 10_000:
return f"{num / 10_000:.1f}w+"
return str(num)
def format_followers(num: int) -> str:
if num >= 100_000_000:
return f"{num / 100_000_000:.1f}亿"
elif num >= 10_000:
return f"{num / 10_000:.1f}万"
return str(num)
PERIOD_UPDATE_RULES = {
"day": "每日 19:00 更新",
"week": "每周一 15:00 更新",
"month": "每月 2号 9:00 更新",
}
PERIOD_LABELS = {"day": "日榜", "week": "周榜", "month": "月榜"}
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>小红书{period_label}榜单 · {date}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #fff1f2 0%, #fff7f0 100%);
min-height: 100vh;
padding: 24px;
color: #1a1a1a;
}}
.header {{
text-align: center;
margin-bottom: 32px;
}}
.header .logo {{
font-size: 48px;
margin-bottom: 8px;
}}
.header h1 {{
font-size: 28px;
font-weight: 700;
color: #ff2442;
margin-bottom: 6px;
}}
.header .meta {{
font-size: 13px;
color: #888;
}}
.notice {{
background: #fff8e1;
border-left: 4px solid #ffb300;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 24px;
font-size: 13px;
color: #5d4037;
}}
.stats-bar {{
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}}
.stat-card {{
flex: 1;
min-width: 120px;
background: #fff;
border-radius: 12px;
padding: 16px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}}
.stat-card .num {{
font-size: 28px;
font-weight: 700;
color: #ff2442;
}}
.stat-card .label {{
font-size: 12px;
color: #888;
margin-top: 4px;
}}
.table-wrap {{
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
margin-bottom: 24px;
}}
table {{
width: 100%;
border-collapse: collapse;
}}
thead th {{
background: linear-gradient(135deg, #ff2442, #ff6b7a);
color: #fff;
padding: 14px 16px;
text-align: left;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}}
tbody tr {{
border-bottom: 1px solid #f5f5f5;
transition: background 0.15s;
}}
tbody tr:hover {{ background: #fff5f5; }}
tbody tr:last-child {{ border-bottom: none; }}
td {{
padding: 12px 16px;
font-size: 14px;
vertical-align: middle;
}}
.rank-badge {{
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-weight: 700;
font-size: 14px;
}}
.rank-1 {{ background: #FFD700; color: #5d4000; }}
.rank-2 {{ background: #C0C0C0; color: #3d3d3d; }}
.rank-3 {{ background: #CD7F32; color: #fff; }}
.rank-other {{ background: #f0f0f0; color: #555; }}
.account-name {{
font-weight: 600;
color: #333;
text-decoration: none;
cursor: pointer;
transition: color 0.15s;
}}
.account-name:hover {{
color: #ff2442;
text-decoration: underline;
}}
.score-bar-wrap {{
display: flex;
align-items: center;
gap: 8px;
}}
.score-bar {{
flex: 1;
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
max-width: 100px;
}}
.score-fill {{
height: 100%;
background: linear-gradient(90deg, #ff2442, #ff8fa3);
border-radius: 4px;
}}
.score-num {{
font-weight: 600;
color: #ff2442;
min-width: 36px;
}}
.interaction {{
font-weight: 600;
color: #ff6b35;
}}
.tag {{
display: inline-block;
background: #fff0f3;
color: #ff2442;
border-radius: 12px;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
}}
.export-bar {{
text-align: center;
margin-bottom: 16px;
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}}
.btn {{
padding: 10px 24px;
border-radius: 24px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: transform 0.1s, box-shadow 0.1s;
}}
.btn:hover {{ transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }}
.btn-primary {{ background: linear-gradient(135deg, #ff2442, #ff6b7a); color: #fff; }}
.btn-secondary {{ background: #fff; color: #ff2442; border: 2px solid #ff2442; }}
.footer {{
text-align: center;
font-size: 12px;
color: #bbb;
padding: 16px;
}}
@media print {{
.export-bar {{ display: none; }}
body {{ background: #fff; padding: 0; }}
.table-wrap {{ box-shadow: none; }}
}}
</style>
</head>
<body>
<div class="header">
<div class="logo">📊</div>
<h1>小红书{period_label} · {category_label}</h1>
<div class="meta">数据日期:{date} | 共 {total} 个账号上榜</div>
</div>
<div class="notice">
💡 <strong>榜单说明</strong>:{update_rule}。<br>
📐 <strong>排名算法</strong>:综合评分根据达人在小红书的 <strong>总粉丝数</strong>、周期内的 <strong>粉丝增量</strong>、<strong>点赞增量</strong>、<strong>收藏增量</strong>、<strong>分享增量</strong> 以及 <strong>评论增量</strong> 加权计算所得(满分100)。
</div>
<div class="stats-bar">
<div class="stat-card"><div class="num">{total}</div><div class="label">上榜账号</div></div>
<div class="stat-card"><div class="num">{top_interaction}</div><div class="label">最高互动</div></div>
<div class="stat-card"><div class="num">{total_notes}</div><div class="label">总新增笔记</div></div>
</div>
<div class="export-bar">
<button class="btn btn-primary" onclick="window.print()">🖨️ 打印 / 导出 PDF</button>
<button class="btn btn-secondary" id="downloadImgBtn" onclick="downloadAsImage()">📷 保存为图片</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script>
async function downloadAsImage() {{
const btn = document.getElementById('downloadImgBtn');
btn.textContent = '⏳ 生成中...';
btn.disabled = true;
try {{
const element = document.querySelector('.table-wrap');
const canvas = await html2canvas(element, {{
scale: 2,
backgroundColor: '#ffffff',
useCORS: true
}});
const link = document.createElement('a');
link.download = '小红书榜单_{date}.png';
link.href = canvas.toDataURL('image/png');
link.click();
btn.textContent = '✅ 下载成功';
setTimeout(() => {{
btn.textContent = '📷 保存为图片';
btn.disabled = false;
}}, 2000);
}} catch (e) {{
alert('生成图片失败,请尝试使用浏览器截图');
btn.textContent = '📷 保存为图片';
btn.disabled = false;
}}
}}
</script>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>排名</th>
<th>账号名</th>
<th>综合评分</th>
<th>总粉丝数</th>
<th>新增笔记数</th>
<th>新增粉丝</th>
<th>新增点赞</th>
<th>新增评论</th>
<th>新增收藏</th>
<th>新增分享</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</div>
</body>
</html>"""
ROW_TEMPLATE = """ <tr>
<td><span class="rank-badge {rank_class}">{rank}</span></td>
<td><a href="{profile_url}" target="_blank" class="account-name" title="点击查看小红书主页">{account_name}</a></td>
<td><span class="score">{score}</span></td>
<td>{followers}</td>
<td>{new_notes}</td>
<td class="interaction">{new_fans}</td>
<td class="interaction">{new_likes}</td>
<td class="interaction">{new_comments}</td>
<td class="interaction">{new_collects}</td>
<td class="interaction">{new_shares}</td>
</tr>"""
def _fmt(val, fmt_fn=None) -> str:
"""通用格式化:None / 空字符串 显示 '-'。
若值为字符串(新接口直接返回如 '254.06w'),直接返回;
若值为整数,调用 fmt_fn 格式化。
"""
if val is None or val == "" or val == "-":
return "-"
# 字符串直接返回(新接口已格式化好)
if isinstance(val, str):
return val if val.strip() else "-"
# 数字
try:
n = int(val)
if n == 0:
return "-"
return fmt_fn(n) if fmt_fn else str(n)
except (TypeError, ValueError):
return str(val) if val else "-"
def generate_html(data: dict, output_path: str):
items = data.get("list", [])
total = data.get("total", len(items))
fetch_time = data.get("fetchTime", data.get("date", "未知"))
period = data.get("period", "day")
date_str = data.get("date", "")
category = data.get("category", "")
period_label = PERIOD_LABELS.get(period, "日榜")
category_label = category + "类" if category else "全品类"
update_rule = PERIOD_UPDATE_RULES.get(period, PERIOD_UPDATE_RULES["day"])
# 手动计算最高互动:点赞 + 评论 + 收藏 + 分享
top_interaction_raw = 0
for i in items:
interaction = (
parse_num(i.get("newLikes")) +
parse_num(i.get("newComments")) +
parse_num(i.get("newCollects")) +
parse_num(i.get("newShares"))
)
if interaction > top_interaction_raw:
top_interaction_raw = interaction
top_interaction = format_interaction(top_interaction_raw)
total_notes = sum(i.get("newNoteCount", 0) or 0 for i in items)
rows = []
for item in items:
rank = item.get("rank", 0)
if rank == 1:
rank_class = "rank-1"
elif rank == 2:
rank_class = "rank-2"
elif rank == 3:
rank_class = "rank-3"
else:
rank_class = "rank-other"
rows.append(ROW_TEMPLATE.format(
rank=rank,
rank_class=rank_class,
account_name=html_utils.escape(item.get("accountName", "")),
profile_url=html_utils.escape(item.get("accountLink") or item.get("profileUrl", "#")),
category=html_utils.escape(infer_category(item.get("accountName", ""), item.get("accountLink") or item.get("profileUrl", ""))),
followers=_fmt(item.get("followers"), format_followers),
new_notes=item.get("newNoteCount", "-") or "-",
new_fans=_fmt(item.get("newFans"), format_interaction),
new_likes=_fmt(item.get("newLikes"), format_interaction),
new_comments=_fmt(item.get("newComments"), format_interaction),
new_collects=_fmt(item.get("newCollects"), format_interaction),
new_shares=_fmt(item.get("newShares"), format_interaction),
score=int(item.get("comprehensiveScore")) if item.get("comprehensiveScore") else "-",
))
html = HTML_TEMPLATE.format(
period_label=period_label,
category_label=category_label,
update_rule=update_rule,
date=date_str,
fetch_time=fetch_time,
total=total,
top_interaction=top_interaction,
total_notes=total_notes,
rows="\n".join(rows),
gen_time=datetime.now().strftime("%Y-%m-%d %H:%M"),
)
Path(output_path).write_text(html, encoding="utf-8")
print(f"[INFO] HTML 报告已生成:{output_path}")
def main():
parser = argparse.ArgumentParser(description="生成小红书榜单 HTML 报告")
parser.add_argument("--data", required=True, help="fetch_rank.py 输出的 JSON 文件路径")
parser.add_argument("--output", default="", help="HTML 输出路径,不填则自动命名")
args = parser.parse_args()
data_path = Path(args.data)
if not data_path.exists():
print(f"[ERROR] 找不到数据文件:{args.data}", file=sys.stderr)
sys.exit(1)
with open(data_path, encoding="utf-8") as f:
data = json.load(f)
if not args.output:
period = data.get("period", "day")
period_label = {"day": "日榜", "week": "周榜", "month": "月榜"}.get(period, "榜单")
category = data.get("category", "") or "全品类"
# 格式:关键词_日期_时间戳,如:小红书美妆日榜_20260505_143022
date_str = data.get("date", "").replace("-", "")
timestamp = datetime.now().strftime("%H%M%S")
keyword = f"小红书{category}{period_label}"
output_path = str(data_path.parent / f"{keyword}_{date_str}_{timestamp}.html")
else:
output_path = args.output
generate_html(data, output_path)
if __name__ == "__main__":
main()