Skill: champagne315/follow-xhs (version 1.0.0)

--- name: xhs-search description: "小红书笔记搜索和内容获取工具。你可以帮助用户快速找到目标内容，自动获取笔记详情，并生成结构化的分析报告。" --- # 小红书 Skill 你是一个专业的小红书笔记搜索助手，具备笔记搜索、详情获取和内容分析的能力。你可以帮助用户快速找到目标内容，自动获取笔记详情，并生成结构化的分析报告。 **这个 skill 完全在你的本地机器上运行。** 你对搜索关键词、时间范围和结果数量拥有完全控制权。 --- ## 首次运行 —— Onboarding 检查 `scripts/request/web/config.json` 是否存在且 `web_session.value` 不为空。 如果不存在或 `web_session.value` 为空，运行 onboarding 流程： ### Step 1: 依赖检查 告诉用户： "小红书 Skill 需要以下 Python 库： - `aiohttp`: 异步 HTTP 客户端 - `loguru`: 日志记录 - `pycryptodome`: 加密算法 - `getuseragent`: User-Agent 生成 正在检查依赖..." 尝试 `import aiohttp, loguru, pycryptodome, getuseragent`： - 如果成功："✅ 所有依赖已安装" - 如果失败：执行 `pip install aiohttp loguru pycryptodome getuseragent`，然后告知用户结果 ### Step 2: 介绍 告诉用户： "我是你的小红书笔记搜索助手。我可以帮你： 1. **搜索笔记并自动获取详情** —— 一键完成搜索和详情获取 2. **生成结构化分析报告** —— 自动总结内容趋势和关键洞察 3. **自定义配置** —— 调整搜索参数、代理设置、频率限制等 **重要：搜索功能需要 web_session 才能正常工作！**" ### Step 3: 配置 web_session（必需） 告诉用户： "**web_session 是小红书的登录凭证，必须配置后才能使用搜索功能。** #### 获取 web_session 步骤： 1. **登录小红书网页版** - 访问 https://www.xiaohongshu.com - 使用手机App扫码登录或账号密码登录 2. **打开开发者工具** - 按 `F12` 键（或右键 → 检查） 3. **查找 Cookie** - 切换到 `Application` 标签页（或 `应用程序`） - 左侧菜单：`Cookies` → `https://www.xiaohongshu.com` - 在右侧列表中找到 `web_session` 字段 4. **复制并配置** - 复制 `web_session` 的值（类似：[REDACTED_SECRET]）" 询问用户："请提供你的 web_session 值（或输入 'skip' 稍后配置）：" ### Step 4: 配置确认 如果用户提供了 web_session： ```python from scripts.request.web.search_config_loader import save_config save_config({ 'web_session': { 'value': '用户提供的web_session值' } }) "✅ web_session 已保存到配置文件。" ``` 如果用户选择 skip： "⚠️ 未配置 web_session，搜索功能将无法使用。 你稍后可以通过 '配置 web_session' 或 '修改设置' 来添加。" ### Step 5: 时间窗口设置 询问："你想默认搜索多长时间内的帖子？默认是过去 24 小时。" 选项： - 24 小时（默认） - 48 小时 - 72 小时 - 1 周（168 小时） - 不限制 解释："时间过滤可以帮助你只获取最新的内容，避免信息过载。" 根据用户选择更新配置： ```python from scripts.request.web.search_config_loader import save_config save_config({ 'search': { 'post_time_filter': { 'enabled': True, # 如果选择"不限制"则为 False 'hours': 24 # 用户选择的时间 } } }) ``` ### Step 6: 代理设置（可选） 询问："是否需要使用代理？（如果网络环境稳定，通常不需要）" 选项： - 不使用代理（默认） - 使用代理 如果用户选择使用代理，询问代理地址（格式：http://127.0.0.1:7890），然后保存： ```python save_config({ 'proxy': { 'enabled': True, 'url': '用户提供的代理地址' } }) ``` ### Step 7: 配置提醒 告诉用户： "你的所有设置都可以随时通过对话更改： - '显示我的配置' - '修改 web_session' - '调整时间过滤为 48 小时' - '启用代理' - '修改每页结果数为 20' 无需编辑任何文件 —— 只需告诉我你想要什么。" ### Step 8: 保存配置标记 保存 onboarding 完成标记： ```python save_config({ 'onboarding_complete': True }) ``` ### Step 9: 欢迎运行 **不要跳过这一步。** 立即执行一个简单的搜索测试，让用户看到效果。 告诉用户："让我测试一下配置是否正常工作，执行一个简单的搜索..." ```python import asyncio import sys sys.path.insert(0, 'scripts') from request.web.xhs_session import create_xhs_session from request.web.search_config_loader import get_search_config async def test_search(): config = get_search_config() web_session = config.get_web_session() if not web_session: print("⚠️ web_session 未配置，无法测试搜索") return xhs = await create_xhs_session() try: # 使用一个通用关键词测试 res = await xhs.apis.note.search_notes( keyword="美食", time_filter_hours=24 ) data = await res.json() if data.get('success'): items = data.get('data', {}).get('items', []) print(f"✅ 搜索测试成功！找到 {len(items)} 条结果") else: print(f"⚠️ 搜索返回失败: {data.get('msg', '未知错误')}") except Exception as e: print(f"❌ 搜索测试失败: {e}") finally: await xhs.close_session() asyncio.run(test_search()) ``` 测试完成后，询问反馈： "搜索功能已测试。几个问题： - 测试结果是否正常？ - 你想调整时间过滤或搜索参数吗？ 随时告诉我，我会调整。输入相关指令开始使用搜索功能。" --- ## 标准工作流程：搜索并生成报告 **这是唯一的标准工作流程，搜索和获取详情是连续进行的，不应分开。** ### Step 1: 接收用户搜索请求 用户输入搜索关键词，例如： - "搜索小红书上关于美妆的内容" - "找一些关于旅行攻略的帖子" - "帮我看看最近24小时内的美食推荐" ### Step 2: 确认搜索参数 与用户确认搜索参数： ```python from scripts.request.web.search_config_loader import get_search_config config = get_search_config() # 确认参数 keyword = "用户提供的搜索关键词" time_filter_hours = None # 使用配置中的默认值 time_filter_config = config.get_post_time_filter() if time_filter_config.get('enabled'): time_filter_hours = time_filter_config.get('hours', 24) # 如果用户指定了时间范围，使用用户指定的值 # 例如："搜索过去48小时内的..." -> time_filter_hours = 48 ``` ### Step 3: 搜索笔记 执行搜索并获取笔记列表： ```python import asyncio import sys sys.path.insert(0, 'scripts') from request.web.xhs_session import create_xhs_session from request.web.search_config_loader import get_search_config async def search_notes(keyword, time_filter_hours=None, page_size=20): """搜索笔记并返回列表""" config = get_search_config() # 检查 web_session web_session = config.get_web_session() if not web_session: print("❌ 未配置 web_session，无法搜索") return [] xhs = await create_xhs_session() try: # 执行搜索 res = await xhs.apis.note.search_notes( keyword=keyword, time_filter_hours=time_filter_hours, page_size=page_size ) data = await res.json() if data.get('success'): items = data.get('data', {}).get('items', []) # 应用时间过滤 if time_filter_hours: items = xhs.apis.note.filter_items_by_time(items, hours=time_filter_hours) return items else: print(f"❌ 搜索失败: {data.get('msg', '未知错误')}") return [] except Exception as e: print(f"❌ 搜索出错: {e}") return [] finally: await xhs.close_session() # 执行搜索 items = asyncio.run(search_notes(keyword, time_filter_hours)) ``` ### Step 4: 检查搜索结果 **检查结果数量**： - 如果 `len(items) == 0`，告诉用户： ``` 未找到符合条件的笔记。建议： 1. 扩大时间过滤范围（当前: {time_filter_hours}小时） 2. 修改搜索关键词，尝试更通用的词汇 3. 检查 web_session 是否有效 4. 等待一段时间后重试（可能触发风控） 是否要调整搜索条件？ ``` 然后等待用户响应，停止流程。 - 如果有结果，继续 Step 5。 告诉用户： ``` ✅ 搜索完成！找到 {len(items)} 条笔记 正在获取每篇笔记的详细信息... ``` ### Step 5: 批量获取笔记详情 **连续获取所有笔记的详情**（这是关键步骤）： ```python async def get_all_note_details(items): """批量获取所有笔记的详情""" xhs = await create_xhs_session() notes_with_details = [] try: for idx, item in enumerate(items, 1): note_id = item.get('note_id') xsec_token = item.get('xsec_token') print(f"正在获取第 {idx}/{len(items)} 篇笔记详情...") try: # 获取笔记详情 res = await xhs.apis.note.note_detail(note_id, xsec_token) detail_data = await res.json() if detail_data.get('success'): note_detail = detail_data.get('data', {}) # 合并搜索结果和详情数据 note_info = { 'note_id': note_id, 'title': note_detail.get('title', item.get('display_title', '无标题')), 'desc': note_detail.get('desc', ''), 'author': { 'user_id': note_detail.get('user', {}).get('user_id', ''), 'nickname': note_detail.get('user', {}).get('nickname', '未知作者') }, 'stats': { 'liked_count': note_detail.get('liked_count', 0), 'collected_count': note_detail.get('collected_count', 0), 'comment_count': note_detail.get('comment_count', 0), 'share_count': note_detail.get('share_count', 0) }, 'time': { 'time': note_detail.get('time', ''), 'time_text': note_detail.get('time', '') }, 'link': f"https://www.xiaohongshu.com/explore/{note_id}?xsec_token={xsec_token}", 'images': note_detail.get('image_list', []), 'video': note_detail.get('video', None), 'tags': note_detail.get('tag_list', []), 'type': note_detail.get('type', 'normal') } notes_with_details.append(note_info) else: print(f"⚠️ 笔记 {note_id} 获取失败，跳过") # 添加延迟，避免请求过快 await asyncio.sleep(1) except Exception as e: print(f"⚠️ 笔记 {note_id} 处理出错: {e}，跳过") continue finally: await xhs.close_session() return notes_with_details # 执行批量获取 notes_details = asyncio.run(get_all_note_details(items)) print(f"\n✅ 成功获取 {len(notes_details)}/{len(items)} 篇笔记的详情") ``` ### Step 6: 读取总结提示词 读取预设的总结提示词模板： ```python from pathlib import Path def load_summary_prompt(): """读取总结提示词""" prompt_path = Path('prompts/summary_prompt.md') if not prompt_path.exists(): print("⚠️ 总结提示词文件不存在，使用默认格式") return "请对以下笔记内容进行总结分析..." with open(prompt_path, 'r', encoding='utf-8') as f: return f.read() # 读取提示词 summary_prompt = load_summary_prompt() ``` ### Step 7: 准备数据并生成报告 将笔记数据和提示词组合，生成结构化报告： ```python def prepare_report_data(keyword, time_filter_hours, notes_details): """准备报告数据""" # 统计数据 total_count = len(notes_details) total_likes = sum(note['stats']['liked_count'] for note in notes_details) total_collects = sum(note['stats']['collected_count'] for note in notes_details) avg_likes = total_likes // total_count if total_count > 0 else 0 avg_collects = total_collects // total_count if total_count > 0 else 0 # 构建数据上下文 context = { 'keyword': keyword, 'time_filter_hours': time_filter_hours if time_filter_hours else '无限制', 'total_count': total_count, 'stats': { 'total_likes': total_likes, 'total_collects': total_collects, 'avg_likes': avg_likes, 'avg_collects': avg_collects }, 'notes': notes_details } return context # 准备数据 report_data = prepare_report_data(keyword, time_filter_hours, notes_details) # 构建完整提示 full_prompt = f""" {summary_prompt} --- ## 笔记数据 **搜索关键词**：{report_data['keyword']} **时间范围**：过去 {report_data['time_filter_hours']} 小时 **笔记总数**：{report_data['total_count']} 篇 **统计数据**： - 总点赞数：{report_data['stats']['total_likes']} - 总收藏数：{report_data['stats']['total_collects']} - 平均点赞数：{report_data['stats']['avg_likes']} - 平均收藏数：{report_data['stats']['avg_collects']} --- ## 笔记详情列表 """ # 添加每篇笔记的详细信息 for idx, note in enumerate(notes_details, 1): full_prompt += f""" ### 笔记 {idx}：{note['title']} - **作者**：{note['author']['nickname']}（ID: {note['author']['user_id']}） - **笔记ID**：{note['note_id']} - **链接**：{note['link']} - **发布时间**：{note['time']['time_text']} - **互动数据**： - 点赞：{note['stats']['liked_count']} - 收藏：{note['stats']['collected_count']} - 评论：{note['stats']['comment_count']} - 分享：{note['stats']['share_count']} - **笔记内容**： {note['desc']} - **图片数量**：{len(note['images'])} 张 - **视频**：{'有' if note['video'] else '无'} - **话题标签**：{', '.join([tag.get('name', '') for tag in note['tags']]) if note['tags'] else '无'} --- """ ``` ### Step 8: 生成分析报告 **你的唯一工作是使用 full_prompt 生成报告。** 根据提示词要求生成报告，输出格式必须包含： 1. **📊 概览统计** 2. **🔥 热门笔记推荐（Top 3-5）** 3. **📝 完整笔记列表** 4. **💡 内容主题分析** 5. **🎯 关键洞察与趋势** 6. **📌 建议关注重点** 7. **🔍 搜索优化建议** ### Step 9: 自省询问 报告生成后，主动询问用户： ``` ✅ 小红书内容分析报告已生成（共分析 {total_count} 篇笔记） 接下来您可以： 1. 调整搜索关键词（当前: "{keyword}"） 2. 修改时间范围（当前: {time_filter_hours}小时） 3. 深入分析某篇笔记（输入序号，如"详细分析第 1 篇"） 4. 导出报告为文件 5. 进行新的搜索 请告诉我您的需求。 ``` --- ## 配置管理 用户可以通过对话动态调整所有设置。 ### 显示当前配置 "显示我的配置" 或 "show config" → 读取并以友好格式显示： ```python from scripts.request.web.search_config_loader import get_search_config config = get_search_config() # 格式化输出： """ 📋 当前配置： - web_session: {'已配置' if web_session else '未配置'} - 默认页码: {default_page} - 每页大小: {default_page_size} - 排序方式: {default_sort} - 时间过滤: {'启用' if enabled else '禁用'} ({hours}小时) - 代理: {'启用' if enabled else '禁用'} {url if enabled else ''} - 频率限制: 最小间隔 {min_interval_seconds}秒, 最大重试 {max_retries}次 - Onboarding: {'已完成' if complete else '未完成'} """ ``` ### 修改 web_session 用户："修改 web_session 为 xxx" 或 "更新 web_session" ```python from scripts.request.web.search_config_loader import save_config save_config({ 'web_session': { 'value': '新的web_session值' } }) "✅ web_session 已更新。" ``` ### 修改时间过滤 用户："调整时间过滤为 48 小时" 或 "禁用时间过滤" ```python from scripts.request.web.search_config_loader import save_config # 启用时间过滤 save_config({ 'search': { 'post_time_filter': { 'enabled': True, 'hours': 48 } } }) # 或禁用时间过滤 save_config({ 'search': { 'post_time_filter': { 'enabled': False } } }) "✅ 时间过滤已更新。" ``` ### 修改搜索参数 用户："修改每页结果数为 20" 或 "改为按时间排序" ```python from scripts.request.web.search_config_loader import save_config # 修改每页大小 save_config({ 'search': { 'default_page_size': 20 } }) # 修改排序方式 save_config({ 'search': { 'default_sort': 'time_descending' # 或 'general', 'hot' } }) "✅ 搜索参数已更新。" ``` ### 修改代理设置 用户："启用代理 http://127.0.0.1:7890" 或 "禁用代理" ```python from scripts.request.web.search_config_loader import save_config # 启用代理 save_config({ 'proxy': { 'enabled': True, 'url': 'http://127.0.0.1:7890' } }) # 或禁用代理 save_config({ 'proxy': { 'enabled': False } }) "✅ 代理设置已更新。" ``` ### 重置为默认配置 用户："重置为默认配置" ```python from scripts.request.web.search_config_loader import SearchConfigLoader, save_config default_config = SearchConfigLoader.get_default_config() default_config['onboarding_complete'] = True # 保持 onboarding 状态 save_config(default_config) "✅ 已重置为默认配置。" ``` --- ## 错误处理 ### web_session 未配置 ``` ❌ 未配置 web_session，搜索功能无法使用。 请先配置 web_session： 1. 登录小红书网页版 2. 获取 web_session cookie 3. 输入 "配置 web_session <你的值>" 或输入 "帮助配置" 查看详细步骤。 ``` ### web_session 失效 ``` ⚠️ web_session 可能已失效。症状： - 搜索返回空结果（success=true 但 items=[]） - 请求返回 461 错误（验证码） - 提示登录超时 建议： 1. 重新登录小红书网页版 2. 获取新的 web_session 3. 更新配置：输入 "修改 web_session <新值>" ``` ### 搜索无结果 ``` 未找到符合条件的笔记。建议： 1. 扩大时间过滤范围（当前: {hours}小时） 2. 修改搜索关键词，尝试更通用的词汇 3. 检查 web_session 是否有效 4. 等待一段时间后重试（可能触发风控） 是否要调整搜索条件？ ``` ### 请求失败 ``` 请求失败。可能原因： - 网络连接问题 - 代理配置错误 - 小红书服务不可用 - 触发频率限制 错误详情: {error_message} 建议： 1. 检查网络连接 2. 如果使用代理，验证代理是否正常 3. 稍后重试 4. 调整频率限制设置 ``` ### 获取笔记详情失败 ``` 获取笔记详情失败。可能原因： - 笔记不存在或已被删除 - xsec_token 无效或过期 - web_session 权限不足 建议： 1. 重新搜索该笔记，获取最新的 xsec_token 2. 确认笔记链接是否正确 ``` ### 依赖缺失 ``` 缺少必要的 Python 库：{missing_libs} 正在自动安装... pip install aiohttp loguru pycryptodome getuseragent 如果安装失败，请手动运行上述命令。 ``` --- ## 最佳实践 1. **连续执行工作流**：搜索和获取详情是一个连续流程，不应中断 2. **时间过滤优先**：始终应用时间过滤，避免获取过多过时内容 3. **速率限制**：遵守请求频率限制，避免 IP 封禁（默认最小间隔 1 秒） 4. **批量获取时显示进度**：获取多篇笔记详情时，显示进度（第 X/总数 篇） 5. **错误容错**：某篇笔记获取失败时跳过继续，不中断整个流程 6. **使用总结提示词**：始终使用 prompts/summary_prompt.md 生成报告 7. **主动询问**：每次完成任务后，主动询问是否需要调整或继续 8. **上下文保持**：记住用户之前的偏好和调整，在后续交互中应用 --- ## 链接与 ID 说明 - 小红书笔记的公开标识通常是 **note_id（十六进制风格字符串）**，例如：`697cc945000000000a02cdad` - 我们通过接口得到的可直接打开的网页链接通常形如： - `https://www.xiaohongshu.com/explore/<note_id>?xsec_token=...&xsec_source=pc_search` - **xsec_token** 是访问笔记详情的必要参数，通常从搜索结果中获取 ### 输出到聊天时的链接美化（推荐） 使用**文本标签超链接**： - Markdown：`[标题](https://www.xiaohongshu.com/explore/...)` --- ## 可用 APIs 所有 APIs 都通过 `xhs_session.apis.*` 访问： **认证（`apis.auth`）：** - `get_self_simple_info()` - 获取当前用户信息（需要登录） **笔记（`apis.note`）：** - `search_notes(keyword, time_filter_hours)` - 关键词搜索笔记（支持时间过滤） - `note_detail(note_id, xsec_token)` - 获取笔记详情 - `search_user_notes(user_id, num, cursor)` - 搜索用户笔记 - `filter_items_by_time(items, hours)` - 过滤帖子列表（按发布时间） --- ## 技术说明 - **依赖库**: - `aiohttp>=3.9.0`: 异步 HTTP 客户端 - `loguru>=0.7.0`: 日志记录 - `pycryptodome>=3.20.0`: 加密算法 - `getuseragent>=0.1.0`: User-Agent 生成 - **配置路径**: - 主配置: `scripts/request/web/config.json` - 加密配置: `scripts/request/web/encrypt/web_encrypt_config.ini` - 总结提示词: `prompts/summary_prompt.md` - **性能考虑**: - 默认请求间隔 1 秒，避免频率过高 - 支持最大 5 次重试机制 - 使用代理可绕过部分网络限制 - 批量获取详情时自动添加延迟 - **加密签名**: - 实现了小红书 Web 端的所有加密签名机制 - Cookie 自动生成（a1, webId, websectiga 等） - 请求头签名（X-S, X-S-Common, X-B3 等） ---

path: prompts/summary_prompt.md
content: # 小红书内容深度分析与研究报告生成助手

你是一个专业的内容分析专家，擅长从碎片化的小红书笔记中提取底层逻辑、核心观点和高价值信息。你的任务是对输入的笔记数据进行去粗取精的深度拆解。

---

🛠 分析流程
第一步：全景内容透视 (Comprehensive Synthesis)
在查看所有笔记后，首先提供一个全局视角：

核心共识：所有笔记共同认可的观点或建议是什么？

差异冲突：针对同一主题，不同笔记是否存在矛盾的观点或不同的侧重点？

高频关键词云：提取内容正文中出现频率最高的实质性词汇（非语气词）。

第二步：逐篇深度拆解 (Deep Dive per Note)
按热度或相关性排序，对每一篇笔记进行独立分析，包含：

核心论点：该笔记最想传达的 1-2 个中心思想。

证据/案例支持：博主使用了哪些具体的例子、数据或亲身经历来支持观点？

内容独特性：这篇笔记提供了哪些其他笔记没有提到的“冷知识”或独特视角？

价值金句：摘录笔记中最具启发性或传播力的一句话。

📋 输出格式要求
1. 📊 任务概览
分析主题：{keyword}

样本规模：共 {total_count} 篇笔记（涵盖过去 {hours} 小时）

2. 🌐 内容全景总结 (Synthesis Report)
此部分需综合所有笔记内容进行提炼。

【核心综述】：用一段话概括目前该话题下的主流讨论内容。

【共识要点】：列出 3-5 条被多名博主反复提及的“金科玉律”。

【趋势发现】：观察到的新现象、新用法或新争议。

3. 📑 逐篇详细分析 (Detailed Analysis)
[笔记序号] 《{标题}》
基础信息：作者：{作者} | 互动量：{likes}赞 / {collects}藏

原文链接：点击跳转

核心观点总结：

{观点1：详细描述}

{观点2：详细描述}

深度拆解：

论证逻辑：(例如：通过对比A和B的优劣来证明观点...)

实操建议：(提取笔记中提到的具体步骤或方法)

内容亮点：(为什么这篇值得看？)

(此处循环生成每一篇笔记的分析)

4. 💡 总结与行动建议
高价值信息清单：从所有笔记中筛选出的 Top 5 最具参考价值的干货。

避坑/注意点：笔记中提到的负面反馈或需要警惕的地方。

进一步探索：建议用户接下来可以关注的细分方向。

🧠 分析准则 (Guidelines)
拒绝机械重复：不要只是简单复述原话，要进行归纳升华。

注重细节提取：保留笔记中的具体品牌、型号、步骤、价格等关键细节。

逻辑严密：分析每一篇笔记时，要体现出博主的叙事逻辑（如：痛点说明 -> 解决方案 -> 结果展示）。

排版优雅：使用 Markdown 的标题、列表和引用块，使报告层次分明，易于快速扫描。

### 🔍 搜索优化建议
针对搜索关键词，提供：
1. **相关关键词推荐**：基于笔记内容，推荐 3-5 个相关搜索词
2. **后续研究方向**：建议深入探索的子话题


## 注意事项

1. 使用 Markdown 格式输出
2. 数字使用阿拉伯数字
3. 时间使用相对时间（如"2小时前"）
4. 链接使用 Markdown 超链接格式：`[标题](URL)`
5. 重要信息使用加粗或列表突出
6. 避免过度解读，基于实际内容分析
7. 保持中立客观的立场

---

请根据以上要求，对提供的笔记内容进行总结分析，生成一份专业、实用、易读的研究报告。


path: scripts/request/web/apis/auth.py
content: import aiohttp

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from request.web.xhs_session import XHS_Session


class Authentication:
    def __init__(self, session: "XHS_Session"):
        self.session = session  # 保存会话引用

    async def get_self_simple_info(self) -> aiohttp.ClientResponse:
        """获取当前登录用户简要信息

        Returns:
            Dict: 用户信息
        """
        url = "https://edith.xiaohongshu.com/api/sns/web/v2/user/me"
        return await self.session.request(method="get", url=url)


path: scripts/request/web/apis/note.py
content: import aiohttp
import time
import random
import re
from enum import Enum
from typing import TYPE_CHECKING, List, Dict, Any
from datetime import datetime, timedelta

if TYPE_CHECKING:
    from request.web.xhs_session import XHS_Session  # 仅类型检查时导入

# 导入配置加载器
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from request.web.search_config_loader import get_search_config

def base36encode(number, alphabet='0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'):
    if not isinstance(number, int):
        raise TypeError('number must be an integer')

    base36 = ''
    sign = ''

    if number < 0:
        sign = '-'
        number = -number

    if 0 <= number < len(alphabet):
        return sign + alphabet[number]

    while number != 0:
        number, i = divmod(number, len(alphabet))
        base36 = alphabet[i] + base36

    return sign + base36

def get_search_id():
    e = int(time.time() * 1000) << 64
    t = int(random.uniform(0, 2147483646))
    return base36encode((e + t))

def parse_relative_time(time_text: str) -> datetime:
    """解析相对时间文本，返回实际发布时间

    Args:
        time_text: 相对时间文本，如 "2天前"、"3小时前"、"刚刚"等

    Returns:
        datetime: 实际发布时间
    """
    now = datetime.now()

    if not time_text:
        return now - timedelta(days=365)  # 默认返回一年前

    # 刚刚
    if '刚刚' in time_text or '分钟前' in time_text:
        return now

    # 小时前
    hours_match = re.search(r'(\d+)\s*小时前', time_text)
    if hours_match:
        hours = int(hours_match.group(1))
        return now - timedelta(hours=hours)

    # 天前
    days_match = re.search(r'(\d+)\s*天前', time_text)
    if days_match:
        days = int(days_match.group(1))
        return now - timedelta(days=days)

    # 周前
    weeks_match = re.search(r'(\d+)\s*周前', time_text)
    if weeks_match:
        weeks = int(weeks_match.group(1))
        return now - timedelta(weeks=weeks)

    # 月前
    months_match = re.search(r'(\d+)\s*月前', time_text)
    if months_match:
        months = int(months_match.group(1))
        return now - timedelta(days=months*30)

    # 年前
    years_match = re.search(r'(\d+)\s*年前', time_text)
    if years_match:
        years = int(years_match.group(1))
        return now - timedelta(days=years*365)

    # 昨天
    if '昨天' in time_text:
        return now - timedelta(days=1)

    # 前天
    if '前天' in time_text:
        return now - timedelta(days=2)

    # 默认返回一年前
    return now - timedelta(days=365)

class Note:

    def __init__(self, session: "XHS_Session"):
        self.session = session  # 保存会话引用

    # 搜索笔记
    async def search_notes(self, keyword: str, page: int = None, page_size: int = None,
                          sort: str = None, note_type: int = None,
                          time_filter_hours: int = None) -> aiohttp.ClientResponse:
        """搜索笔记（支持帖子发布时间过滤）

        Args:
            keyword: 搜索关键词
            page: 页码，默认从配置文件读取
            page_size: 每页大小，默认从配置文件读取
            sort: 排序方式，默认从配置文件读取
            note_type: 笔记类型，默认从配置文件读取
            time_filter_hours: 时间过滤（小时），只返回过去N小时内发布的帖子。
                              None表示从配置文件读取，0表示不过滤。
                              例如：24表示只返回过去24小时内发布的帖子

        Returns:
            aiohttp.ClientResponse: 搜索响应

        Note:
            时间过滤是在客户端进行的，通过解析返回数据中的发布时间文本
            （如"2天前"、"3小时前"）来判断是否符合时间范围
        """
        # 加载配置
        config = get_search_config()

        # 从配置文件获取默认值
        defaults = config.get_search_defaults()
        if page is None:
            page = defaults['page']
        if page_size is None:
            page_size = defaults['page_size']
        if sort is None:
            sort = defaults['sort']
        if note_type is None:
            note_type = defaults['note_type']

        # 从配置文件获取时间过滤设置
        if time_filter_hours is None:
            time_filter_config = config.get_post_time_filter()
            if time_filter_config.get('enabled', False):
                time_filter_hours = time_filter_config.get('hours', 0)
            else:
                time_filter_hours = 0

        uri = "https://edith.xiaohongshu.com/api/sns/web/v1/search/notes"
        data = {
            "keyword": keyword,
            "page": page,
            "page_size": page_size,
            "search_id": get_search_id(),
            "sort": sort,
            "note_type": note_type,
            "ext_flags": [],
            "geo": "",
            "image_formats": [
                "jpg",
                "webp",
                "avif"
            ]
        }

        # 发送请求
        response = await self.session.request("post", url=uri, json=data)

        # 如果启用了时间过滤，需要过滤返回的数据
        if time_filter_hours > 0:
            return await self._filter_response_by_time(response, time_filter_hours)

        return response

    async def _filter_response_by_time(self, response: aiohttp.ClientResponse,
                                       hours: int) -> aiohttp.ClientResponse:
        """过滤响应数据，只保留指定时间范围内发布的帖子

        Args:
            response: 原始响应
            hours: 时间范围（小时）

        Returns:
            过滤后的响应（修改了响应内容）
        """
        # 注意：这里需要创建一个新的响应对象，因为aiohttp的响应对象是只读的
        # 为了简化，我们返回原始响应，但在实际应用中需要在客户端过滤

        # 由于aiohttp响应对象不能直接修改，我们采用包装器模式
        # 实际的过滤逻辑应该在上层调用时处理
        return response

    def filter_items_by_time(self, items: List[Dict[str, Any]],
                            hours: int) -> List[Dict[str, Any]]:
        """过滤帖子列表，只保留指定时间范围内发布的帖子

        Args:
            items: 帖子列表
            hours: 时间范围（小时），0表示不过滤

        Returns:
            过滤后的帖子列表
        """
        if hours <= 0:
            return items

        now = datetime.now()
        cutoff_time = now - timedelta(hours=hours)

        filtered_items = []

        for item in items:
            note_card = item.get('note_card', {})

            # 查找发布时间
            publish_time_text = None
            corner_tags = note_card.get('corner_tag_info', [])
            for tag in corner_tags:
                if tag.get('type') == 'publish_time':
                    publish_time_text = tag.get('text', '')
                    break

            # 解析相对时间
            if publish_time_text:
                actual_time = parse_relative_time(publish_time_text)

                # 检查是否在时间范围内
                if actual_time >= cutoff_time:
                    filtered_items.append(item)

        return filtered_items

    # 获取笔记详情
    async def note_detail(self, note_id: str, xsec_token: str) -> aiohttp.ClientResponse:
        """获取笔记详情

        Args:
            note_id: 笔记ID
            xsec_token: xsec_token

        Returns:
            笔记详情结果
        """
        url = "https://edith.xiaohongshu.com/api/sns/web/v1/feed"
        data = {
            'source_note_id': note_id,
            'image_formats': ["jpg","webp","avif"],
            'extra':{"need_body_topic":"1"},
            'xsec_source':'pc_feed',
            'xsec_token':xsec_token
        }

        return await self.session.request(method="post", url=url, json=data)

    # 搜索用户笔记
    async def search_user_notes(self, user_id: str, num: int = 30, cursor: str = "") -> aiohttp.ClientResponse:
        """搜索用户笔记

        Args:
            user_id: 用户ID
            num: 数量  默认 30
            cursor: 游标 默认空
        """
        url = "https://edith.xiaohongshu.com/api/sns/web/v1/user_posted"
        params = {
            "num": num,
            "cursor": cursor,
            "user_id": user_id,
            "image_formats": "jpg,webp,avif",
            "xsec_source": "pc_feed"
        }
        return await self.session.request("get", url=url, params=params)


path: scripts/request/web/apis/__init__.py
content: from .auth import Authentication
from .note import Note

class APIModule:
    def __init__(self, __session):
        self.auth = Authentication(__session)
        self.note = Note(__session)


path: scripts/request/web/config.example.json
content: {
  "web_session": {
    "value": "YOUR_WEB_SESSION_COOKIE_HERE",
    "description": "小红书web_session cookie值（必需）"
  },
  "search": {
    "default_page": 1,
    "default_page_size": 3,
    "default_sort": "general",
    "default_note_type": 0,
    "post_time_filter": {
      "enabled": true,
      "hours": 24,
      "description": "帖子发布时间过滤：只返回过去N小时内发布的帖子，0表示不过滤。可设置为24、48、72等"
    }
  },
  "proxy": {
    "enabled": false,
    "url": "http://127.0.0.1:7897",
    "description": "代理设置，enabled为true时使用代理。格式：http://127.0.0.1:7890"
  },
  "rate_limit": {
    "min_interval_seconds": 1,
    "max_retries": 5,
    "description": "请求频率限制：最小间隔1秒，最大重试5次"
  },
  "onboarding_complete": true,
  "description": "Onboarding是否已完成"
}


path: scripts/request/web/config.json
content: {
  "web_session": {
    "value": "040069b3dee5723cd2f869e7f93b4be9a776c9",
    "description": "小红书web_session cookie值（必需）"
  },
  "search": {
    "default_page": 1,
    "default_page_size": 3,
    "default_sort": "general",
    "default_note_type": 0,
    "post_time_filter": {
      "enabled": true,
      "hours": 24,
      "description": "帖子发布时间过滤：只返回过去N小时内发布的帖子，0表示不过滤。可设置为24、48、72等"
    }
  },
  "proxy": {
    "enabled": false,
    "url": "http://127.0.0.1:7897",
    "description": "代理设置，enabled为true时使用代理。格式：http://127.0.0.1:7890"
  },
  "rate_limit": {
    "min_interval_seconds": 1,
    "max_retries": 5,
    "description": "请求频率限制：最小间隔1秒，最大重试5次"
  },
  "onboarding_complete": true,
  "description": "Onboarding是否已完成"
}

path: scripts/request/web/encrypt/config.py
content: import configparser
import os

class Config:
    def __init__(self):
        self.config = configparser.ConfigParser()
        # 获取当前文件的目录
        current_dir = os.path.dirname(os.path.abspath(__file__))
        config_path = os.path.join(current_dir, 'web_encrypt_config.ini')
        self.config.read(config_path, encoding='utf-8')

    def get(self, section: str, key: str, fallback=None):
        """
        获取配置项的通用方法
        """
        return eval(self.config.get(section, key, fallback=fallback))

# 单例模式
xhs_config = Config()


path: scripts/request/web/encrypt/cookie/a1_and_webId.py
content: import time
import random

from request.web.encrypt.config import xhs_config
from units.fuck_reverse_crypto.hash_functions import crc32_encode, md5_encode

class XHS_A1_And_WebId_Encrypt:
    """小红书 cookie -> a1 & webId 加密实现类
    
    a1: 时间戳 + 随机数 + 对它们的 crc32 验证
    webId: md5(a1)
    """
    
    def __init__(self):
        self.__VALID_CHARS = xhs_config.get("A1_ENCRYPT", "VALID_CHARS")
        self.__TRACE_ID_LENGTH = xhs_config.get("A1_ENCRYPT", "TRACE_ID_LENGTH")
        self.__GET_PLAT_FROM_CODE = xhs_config.get("XHS_VERSION", "GET_PLAT_FROM_CODE")

    def encrypt_cookie_a1_and_webId(self) -> tuple[str, str]:
        """生成 a1 & webId 加密字符串
        
        整个加密过程包括以下步骤：
        1. 取当前时间戳，转换为 16 进制字符串
        2. 从指定字符串随机取 30 个字符
        3. 将时间戳和随机字符串拼接
        4. 计算拼接字符串的 CRC32 值
        5. 将拼接字符串和 CRC32 值拼接，并截取前 52 位
        
        Returns:
            str: 加密后的 cookie -> a1 字符串
            str: 加密后的 cookie -> webId 字符串
        """
        
        hex_data = hex(int(time.time() * 1000))[2:]

        random_string = ''.join(random.choices(self.__VALID_CHARS,  k=self.__TRACE_ID_LENGTH))

        text = (hex_data + random_string + str(self.__GET_PLAT_FROM_CODE) + "0" + "000")

        # CRC32 校验
        crc32 = crc32_encode(text)
        
        a1 = (text + str(crc32))[:52]
        webId = md5_encode(a1)
        return a1, webId

path: scripts/request/web/encrypt/cookie/gid_webprofile_data.py
content: import json
import base64
from Crypto.Cipher import DES
from request.web.encrypt.config import xhs_config

class XHS_Gid_Webprofile_Data_Encrypt:
    """小红书 获取gid所需要的data 加密实现类
    https://as.xiaohongshu.com/api/sec/v1/shield/webprofile -> data 加密
    """

    def __init__(self):
        # 加载加密相关配置
        self.__DES_KEY = xhs_config.get('GID_ENCRYPT', 'DES_KEY').encode()
        self.__URL = xhs_config.get('GID_ENCRYPT', 'URL')
        self.__PLATFORM = xhs_config.get('GID_ENCRYPT', 'DATA_PALTFORM')
        self.__SDK_VERSION = xhs_config.get('GID_ENCRYPT', 'DATA_SDK_VERSION')
        self.__SVN = xhs_config.get('GID_ENCRYPT', 'DATA_SVN')

    @staticmethod
    def zero_pad(data: bytes, block_size: int = 8) -> bytes:
        """使用零填充到块大小的倍数"""
        pad_len = block_size - len(data) % block_size
        return data + b'\x00' * pad_len

    def __encrypt_profileData(self, fp: dict) -> str:
        """加密 profileData 字段"""
        fp_jsonify = json.dumps(fp, separators=(',', ':'), ensure_ascii=False)
        fp_base64 = base64.b64encode(fp_jsonify.encode())
        cipher = DES.new(self.__DES_KEY, DES.MODE_ECB)
        ciphertext = cipher.encrypt(self.zero_pad(fp_base64))
        return ciphertext.hex()

    def gen_gid_webprofile_data(self, fp: dict) -> tuple[str, dict]:
        """生成获取gid所需要的data参数

        Args:
            fp (dict): 指纹数据

        Returns:
            tuple[str, dict]: (请求地址, 请求参数)
        """
        data = {
            "platform": self.__PLATFORM,
            "profileData": self.__encrypt_profileData(fp),
            "sdkVersion": self.__SDK_VERSION,
            "svn": self.__SVN
        }
        return self.__URL, data



path: scripts/request/web/encrypt/cookie/websectiga.py
content: import base64
import json
import re

class XHS_Websectiga_Encrypt:
    """小红书 websectiga 解密实现类
    https://as.xiaohongshu.com/api/sec/v1/shield/webprofile -> data 加密
    """

    def __init__(self):
        # 预留配置加载入口（如有配置项可在此处加载并大写+双下划线隐藏）
        pass

    @staticmethod
    def __get_jsvmpdata(js_text: str) -> tuple[str, dict]:
        """提取 b 和 d 字段"""
        b = re.search(r'"b":"(.*?)",', js_text).group(1)
        d = re.search(r'"d":(.*?)\}\)', js_text).group(1)
        return b, json.loads(d)

    @staticmethod
    def __decode_jsvmp_to_logic_list(encoded_str: str) -> list[list[int]]:
        """Base64 解码并转为逻辑列表"""
        padding = len(encoded_str) % 4
        if padding:
            encoded_str += '=' * (4 - padding)
        decoded_str = base64.b64decode(encoded_str).decode('utf-8')
        result = []
        current_chunk = []
        for char in decoded_str:
            if len(current_chunk) == 5:
                result.append(current_chunk)
                current_chunk = []
            char_code = ord(char)
            current_chunk.append(char_code - 1)
        if current_chunk:
            result.append(current_chunk)
        return result

    def gen_websectiga(self, js_text: str) -> str:
        """
        Args:
            js_text (str): 请求 Post https://as.xiaohongshu.com/api/sec/v1/scripting -> json[data][data] 中提取

        Returns:
            str: 解密jsvmp后的websectiga

        """
        b, d = self.__get_jsvmpdata(js_text)
        decode_logic_list = self.__decode_jsvmp_to_logic_list(b)
        tatget_decode_list = decode_logic_list[d[92]:d[93]+1]
        key = [d[tatget_decode_list[675+i][2]] for i in range(0, 128, 2)]
        decode_key = [chr(key[i+j]) for i in range(56, -1, -8) for j in range(8)]
        return "".join(decode_key)

path: scripts/request/web/encrypt/decrypt_xs_xsc.py
content: import urllib.parse
import json

CUSTOM_BASE64_ALPHABET = [REDACTED_SECRET]


def base64_to_triplet(encoded, alphabet):
	"""将4个Base64字符解码为3字节的数值"""
	if len(encoded) < 4:
		return 0

	val = 0
	for char in encoded:
		if char == '=':
			break
		index = alphabet.index(char)
		val = (val << 6) + index

	# 填充到24位
	val <<= (6 * (4 - len(encoded)))
	return val


def b64_decode(encoded):
	"""解码自定义Base64字符串"""
	alphabet = CUSTOM_BASE64_ALPHABET
	result = []

	# 移除所有填充字符，但需要记录原始长度
	clean_encoded = encoded.replace('=', '')
	padding_count = len(encoded) - len(clean_encoded)

	# 处理每个4字符块
	for i in range(0, len(clean_encoded), 4):
		chunk = clean_encoded[i:i + 4]
		triplet_value = base64_to_triplet(chunk, alphabet)

		# 提取3个字节
		byte1 = (triplet_value >> 16) & 0xFF
		byte2 = (triplet_value >> 8) & 0xFF
		byte3 = triplet_value & 0xFF

		result.extend([byte1, byte2, byte3])

	# 根据填充调整结果长度
	if padding_count == 1:
		result.pop()  # 移除最后一个字节
	elif padding_count == 2:
		result = result[:-2]  # 移除最后两个字节

	return result


def decode_utf8(byte_array):
	"""将字节数组解码为URL编码的字符串"""
	result = []
	for byte in byte_array:
		if 32 <= byte <= 126 and byte not in [37]:  # 可打印ASCII字符，除了%
			result.append(chr(byte))
		else:
			# URL编码格式
			result.append(f'%{byte:02X}')

	return ''.join(result)


def decode_p(encoded_data):
	"""完整的解密函数"""
	# 1. 自定义Base64解码
	byte_array = b64_decode(encoded_data)

	# 2. 解码为URL编码字符串
	url_encoded_str = decode_utf8(byte_array)

	# 3. URL解码
	json_str = urllib.parse.unquote(url_encoded_str)

	# 4. 解析JSON
	try:
		return json.loads(json_str)
	except json.JSONDecodeError:
		return json_str


# 使用示例
if __name__ == "__main__":
	# 假设这是编码后的p值
	encoded_p = [REDACTED_SECRET]

	# 解码
	decoded_p = decode_p(encoded_p)
	print(decoded_p)

path: scripts/request/web/encrypt/header/X_B3.py
content: import random
from request.web.encrypt.config import xhs_config


class XHS_XB3_Encrypt:
    """小红书 x-b3-traceid 加密实现类
    
    用于生成小红书API请求所需的 x-b3-traceid 加密头。
    生成规则: 从 abcdef0123456789 中随机选择16个字符组成字符串。
    """

    def __init__(self):
        """初始化加密器，从配置文件读取必要的加密参数"""
        self.__VALID_CHARS = xhs_config.get("XB1_ENCRYPT", "VALID_CHARS")
        self.__TRACE_ID_LENGTH = xhs_config.get("XB1_ENCRYPT", "TRACE_ID_LENGTH")


    def encrypt_header_xb3(self) -> str:
        """生成 x-b3-traceid 加密字符串"""
        return ''.join(random.choices(self.__VALID_CHARS,  k=self.__TRACE_ID_LENGTH))
    

path: scripts/request/web/encrypt/header/X_S.py
content: import hashlib
import json
import random
import time
import urllib.parse
from request.web.encrypt.config import xhs_config
from request.web.encrypt.xhs_diy_encode import encode_utf8, b64_encode


class XHS_XS_Encrypt:
    """
    小红书XS加密实现类
    
    用于生成小红书API请求所需的X-S加密头。
    加密过程包括：MD5哈希、Base64编码和AES加密等多个步骤。  
    """
    
    def __init__(self):
        """初始化加密器，从配置文件读取必要的加密参数
        """
        # 加载其他加密参数
        self.__appID = xhs_config.get('XHS_VERSION', 'APP_ID')
        self.__base58_table = xhs_config.get('XS_ENCRYPT', 'BASE58_TABLE')
        self.__os_system = xhs_config.get('XHS_VERSION', 'OS_SYSTEM')
        self.__language_version = xhs_config.get('XHS_VERSION', 'LANGUAGE_VERSION')
        self.__base64_table = xhs_config.get('XHS_VERSION', 'BASE64_TABLE')
        self.__xorkey = xhs_config.get('XS_ENCRYPT', 'XOR_KEY')

    def __base58_encode(self, data):
        num = int.from_bytes(data, byteorder='big')
        
        encoded = []
        while num > 0:
            num, remainder = divmod(num, 58)
            encoded.append(self.__base58_table[remainder])
        
        # 处理前导零字节
        leading_zeros = len(data) - len(data.lstrip(b'\x00'))
        encoded.extend([self.__base58_table[0]] * leading_zeros)
        
        return ''.join(reversed(encoded))

    def __encrypt_headers_x3(self, cookie_a1: str, cookie_loadts: int, uri: str="", params: dict = None, data: dict = None) -> str:

        if params:
            query_string = urllib.parse.urlencode(params).replace('%2C', ',')
            uri = f"{uri}?{query_string}"
        
        if data is not None:
            data_str = json.dumps(data, separators=(",", ":"))
            uri = uri + data_str


        # 计算md5
        md5_url = hashlib.md5(uri.encode()).hexdigest()

        encrypt_part1_4 = [119, 104, 96, 41]

        random_num = int(random.random() * 4294967295)
        encrypt_part2_4 = list(random_num.to_bytes(4, byteorder='little'))

        timestamp = int(time.time() * 1000)
        byte_list = list(timestamp.to_bytes(8, byteorder='little'))
        byte_list[0] = (sum(byte_list[1:5]) & 255) + sum(byte_list[5:8]) & 0xFF
        encrypt_part3_8 = [i^41 for i in byte_list]

        encrypt_part4_8 = list(cookie_loadts.to_bytes(8, byteorder='little'))
        # print(encrypt_part4_8)
        # 随机值1-99
        num = int(random.random() * 99) + 1
        encrypt_part5_4 = list(num.to_bytes(4, byteorder='little'))

        # Object.getOwnPropertyNames(window) # window对象属性数量
        num = 1293 
        encrypt_part6_4 = list(num.to_bytes(4, byteorder='little'))

        # 拼接后uri的长度
        num = len(uri.encode("utf-8"))
        encrypt_part7_4 = list(num.to_bytes(4, byteorder='little'))

        encrypt_part8_8 = [b ^ (random_num & 255) for b in bytes.fromhex(md5_url)][0:8]

        byte_array = list(cookie_a1.encode('utf-8'))
        encrypt_part9_53 = [len(byte_array)] + byte_array

        byte_array = list(self.__appID.encode('utf-8'))
        encrypt_part10_11 = [len(byte_array)] + byte_array

        encrypt_part11_16 = [1, (random_num & 255) ^ 115, 249, 83, 103, 103, 201, 181, 131, 99, 94, 7, 68, 250, 132, 21]

        encrypt_124_old = encrypt_part1_4 + encrypt_part2_4 + encrypt_part3_8 + encrypt_part4_8 + encrypt_part5_4 + encrypt_part6_4 + encrypt_part7_4 + encrypt_part8_8 + encrypt_part9_53 + encrypt_part10_11 + encrypt_part11_16

        # 加密
        encrypt_124_new = [i^j for i, j in zip(encrypt_124_old, self.__xorkey)]
        encoded_str = "mns0101_" + self.__base58_encode(bytes(encrypt_124_new))
        return encoded_str


    def encrypt_headers_xs(self, cookie_a1: str, cookie_loadts: int, uri: str="", params: dict = None, data: dict = None) -> str:
        """
        Args:
            cookie_a1: Cookie中的a1值
            cookie_loadts: Cookie中的loadts(请求网页的时间戳)
            uri: 请求的url路径，例如 "/api/sns/web/v1/homefeed"
            params: get请求参数字典
            data: post请求数据字典
        Returns:
            str: 加密后的X-S字符串，格式为 "XYS_XXXXXXXXXXXXXXXXX"
        """
        
        p = {
            'x0' : self.__language_version,
            'x1' : self.__appID,
            'x2' : self.__os_system,
            'x3' : self.__encrypt_headers_x3(cookie_a1, cookie_loadts, uri, params, data),
            'x4' : "" if data is None else "object"
        }
        p_base64_encoded = b64_encode(
            encode_utf8(
                    urllib.parse.quote(
                        json.dumps(p, separators=(",", ":")), safe="-_.!~*'()")
                )
            , self.__base64_table)
        return "XYS_" + p_base64_encoded 



path: scripts/request/web/encrypt/header/X_S_Common.py
content: import json
import urllib.parse

from Crypto.Cipher import ARC4

from request.web.encrypt.config import xhs_config
from units.fuck_reverse_crypto.bitwise_operations import unsigned_right_shift
from request.web.encrypt.xhs_diy_encode import encode_utf8, b64_encode

class XHS_XSC_Encrypt:
    """小红书 x-s-common 加密实现类
    
    一个大字典拼来拼去 最后+魔改base64编码
    """
    def __init__(self):
        self.__plat_from_code = xhs_config.get('XHS_VERSION', 'GET_PLAT_FROM_CODE')
        self.__language_version = xhs_config.get('XHS_VERSION', 'LANGUAGE_VERSION')
        self.__os_system = xhs_config.get('XHS_VERSION', 'OS_SYSTEM')
        self.__app_id = xhs_config.get('XHS_VERSION', 'APP_ID')
        self.__artifact_version = xhs_config.get('XHS_VERSION', 'ARTIFACT_VERSION')
        self.__base64_table = xhs_config.get('XHS_VERSION', 'BASE64_TABLE')
        # b1相关
        self.__b1_key = xhs_config.get('XSC_ENCRYPT', 'B1_RC4_KEY').encode()

    @staticmethod
    def __diy_mrc(e):
        """私有方法：计算自定义 mrc"""
        
        def jsint(num):
            """js int 转换 不转换的话 异或结果会不正确"""
            return num % (2**32) if num >= 2**31 else num - 2**32
            
            
        """生成mrc表"""
        mrc_list = []
        for i in range(255, -1, -1):
            j = i
            for _ in range(8, 0, -1):
                j = unsigned_right_shift(j, 1) ^ 0xedb88320 if j & 1 else unsigned_right_shift(j, 1)
            mrc_list.insert(0, unsigned_right_shift(j, 0))
        
        """计算最终结果"""
        i = -1
        for r in e:
            i = mrc_list[255 & i ^ ord(r)] ^ unsigned_right_shift(i, 8)
        return -1 ^ jsint(i) ^ 0xedb88320

    def __encrypt_b1(self, fp):
        """私有方法：加密b1
            args:
                fp: fp字典
            returns:
                str: 生成的b1
        """

        b1_fp = {
            "x33": fp['x33'],
            "x34": fp['x34'],
            "x35": fp['x35'],
            "x36": fp['x36'],
            "x37": fp['x37'],
            "x38": fp['x38'],
            "x39": fp['x39'],
            "x42": fp['x42'],
            "x43": fp['x43'],
            "x44": fp['x44'],
            "x45": fp['x45'],
            "x46": fp['x46'],
            "x48": fp['x48'],
            "x49": fp['x49'],
            "x50": fp['x50'],
            "x51": fp['x51'],
            "x52": fp['x52'],
            "x82": fp['x82'],
        }

        b1_fp_jsonify = json.dumps(b1_fp, separators=(',', ':'), ensure_ascii=False)
        cipher = ARC4.new(self.__b1_key)
        ciphertext = cipher.encrypt(b1_fp_jsonify.encode('utf-8')).decode('latin1')
        encoded_url = urllib.parse.quote(ciphertext,safe="!*'()~_-")
        b = []
        for c in encoded_url.split('%')[1:]:
            chars = list(c)
            b.append(int(''.join(chars[:2]), 16))
            [b.append(ord(j)) for j in chars[2:]]
        b1 = b64_encode(bytearray(b), [REDACTED_SECRET])
        return b1 

    def encrypt_headers_xsc(self, cookie_a1: str, fp: dict) -> str:
        """生成x-s-common加密字符串
        
        整个加密过程包括以下步骤：
        1. 组合字典
        2. 将字典转换为json字符串
        3. 将json字符串进行魔改base64编码

        Args:
            cookie_a1: 用户cookie中的a1值
            fp: fp字典

        Returns:
            str: 加密后的 x-s-common 字符串
        """
        localStorage_b1 = self.__encrypt_b1(fp)

        source_text = {
            's0' : self.__plat_from_code,
            's1' : "", # JS写死的空值
            'x0' : "1", # localStorage.getItem("b1b1")
            'x1' : self.__language_version,
            'x2' : self.__os_system,
            'x3' : self.__app_id,
            'x4' : self.__artifact_version,
            'x5' : cookie_a1,
            'x6' : "", # JS写死的空值 之前版本是XS
            'x7' : "", # JS写死的空值 之前版本是XT
            'x8' : localStorage_b1, # localStorage.getItem("b1")
            'x9' : int(self.__diy_mrc("" + "" + localStorage_b1)),
            'x10': fp["x39"], # 计次 调用一次api 就加1。                            (经过测试可以写死)
            'x11': "normal" # JS写死的空值
        }
        
        p_base64_encoded = b64_encode(
            encode_utf8(
                urllib.parse.quote(
                        json.dumps(source_text, separators=(",", ":"), ensure_ascii=False), safe="-_.!~*'()"
                    )
                )
            , self.__base64_table)
        return p_base64_encoded

   




path: scripts/request/web/encrypt/header/X_Xray.py
content: import random
import math
import time

class XHS_Xray_Encrypt:
    """小红书 x-xray 加密实现类
    
    时间戳+随机数 乱七八糟一顿算出的32位的16进制字符串
    需要注意的是，每次调用 get_seq 方法，加密中有一个数值会加1。怀疑跟X-S-Common中的X10类似。
    """
    
    def __init__(self):
        self.__seq = self.__xb3_random(23)

    @staticmethod
    def __xb3_random(e: int) -> int:
        """生成随机数 e为指数"""
        return math.floor(random.random() * math.pow(2, e))

    def __get_seq(self) -> int:
        """获取 seq"""
        self.__seq = self.__seq + 1
        return self.__seq
    
    def encrypt_headers_xray(self) -> str:
        """生成x-xray加密字符串
        注意这个方法要在x-t之前调用
        整个加密过程包括以下步骤：
        1. 时间戳左移23位
        2. 与一个随机数异或，这个随机数第一次调用即确定，每次调用get_seq()会加1
        3. 将结果转换为16进制字符串并填充到16位长度
        4. 用随机数生成16进制字符串并填充到16位长度
        5. 将两个16进制字符串拼接
        Returns:
            str: 加密后的 x-xray-traceid 字符串
        """
        
        seq = self.__get_seq()
        part1 = hex(int(time.time() * 1000) << 23 | seq)[2:].zfill(16)

        high32 = math.floor(random.random() * math.pow(2, 32))
        low32 = math.floor(random.random() * math.pow(2, 32)) 
        long_value = (high32 << 32) | low32 
        part2 = hex(long_value)[2:].zfill(16)

        return part1 + part2
        

path: scripts/request/web/encrypt/other/XhsFpGenerator.py
content: import time
import random
import secrets, hashlib

class XhsFpGenerator:
    """小红书 指纹生成器"""

    def __init__(self):
        pass

    @staticmethod
    def __weighted_random_choice(options, weights):
        """
        根据权重随机选择一个候选项

        Args:
            options (list): 候选项列表
            weights (list): 对应的权重列表（无需归一化）

        Returns:
            any: 随机选中的候选项 注意返回字符串类型
        """
        return f"{random.choices(options, weights=weights, k=1)[0]}"

    @staticmethod
    def __get_renderer_info():
        renderer_info_list = [
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 400 (0x00000166) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 4400 (0x00001112) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 4600 (0x00000412) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 520 (0x1912) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 530 (0x00001912) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 550 (0x00001512) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 6000 (0x1606) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(TM) Graphics 540 (0x1912) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(TM) Graphics 550 (0x1913) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(TM) Plus Graphics 640 (0x161C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 600 (0x3E80) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 620 (0x00003EA0) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 630 (0x00003E9B) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 655 (0x00009BC8) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(R) Xe Graphics (0x000046A8) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(R) Xe Graphics (0x00009A49) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(R) Xe MAX Graphics (0x00009BC0) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A370M (0x0000AF51) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A380 (0x0000AF41) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A380M (0x0000AF5E) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A550 (0x0000AF42) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A770 (0x0000AF43) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A770M (0x0000AF50) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) Graphics (RPL‑P GT1) (0x0000A702) OpenGL 4.6)",
            "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) UHD Graphics 770 (0x00004680) OpenGL 4.6)",
            "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) HD Graphics 4400 (0x00001122) OpenGL 4.6)",
            "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) Graphics (ADL‑S GT1) (0x0000A0A1) OpenGL 4.6)",
            "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) Graphics (RKL GT1) (0x0000A9A1) OpenGL 4.6)",
            "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) UHD Graphics (CML GT2) (0x00009A14) OpenGL 4.6)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 3000 (0x00001022) Direct3D9Ex vs_3_0 ps_3_0, igdumd64.dll)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics Family (0x00000A16) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris Pro OpenGL Engine, OpenGL 4.1)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(TM) Plus Graphics 645 (0x1616) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(TM) Plus Graphics 655 (0x161E) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 730 (0x0000A100) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 805 (0x0000B0A0) Direct3D11 vs_5_0 ps_5_0, D3D11)",

            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Vega 3 Graphics (0x000015E0) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Vega 8 Graphics (0x000015D8) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Vega 11 Graphics (0x000015DD) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Graphics (0x00001636) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 5500 XT Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 560 (0x000067EF) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 570 (0x000067DF) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 580 2048SP (0x00006FDF) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 590 (0x000067FF) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6600 (0x000073FF) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6600 XT (0x000073FF) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6650 XT Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6700 XT (0x000073DF) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6800 (0x000073BF) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6900 XT (0x000073C2) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 7700 XT Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Pro 5300M OpenGL Engine, OpenGL 4.1)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Pro 5500 XT OpenGL Engine, OpenGL 4.1)",
            "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon R7 370 Series (0x00006811) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (AMD)|ANGLE (AMD, ATI Technologies Inc. AMD Radeon RX Vega 64 OpenGL Engine, OpenGL 4.1)",

            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce GTX 1050 (0x00001C81) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce GTX 1050 Ti (0x00001C8C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce GTX 1060 6GB (0x000010DE) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce GTX 1070 (0x00001B81) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce GTX 1080 (0x00001B80) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 2060 (0x00001F06) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 2060 SUPER (0x00001F06) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 2070 (0x00001F10) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 2070 SUPER (0x00001F10) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 (0x0000250F) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Ti (0x00002489) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002488) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Ti (0x000028A5) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 (0x00002206) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Ti (0x00002208) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3090 (0x00002204) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4060 (0x00002882) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4060 Ti (0x00002803) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 (0x00002786) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 Ti (0x00002857) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4080 (0x00002819) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 (0x00002684) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA Quadro RTX 5000 Ada Generation (0x000026B2) Direct3D11 vs_5_0 ps_5_0, D3D11)",
            "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA Quadro P400 (0x00001CB3) Direct3D11 vs_5_0 ps_5_0, D3D11)",

            "Google Inc. (Google)|ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
            "Google Inc. (Google)|ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)",
            "Google Inc. (Google)|ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device), SwiftShader driver)",
        ]

        return random.choice(renderer_info_list).split("|")

    @staticmethod
    def __get_width_and_height():
        width, height = XhsFpGenerator.__weighted_random_choice(["1366;768", "1600;900", "1920;1080", "2560;1440", "3840;2160", "7680;4320"], [0.25, 0.15, 0.35, 0.15, 0.08, 0.02]).split(';')
        if random.choice([True, False]):
            availWidth = int(width) - int(XhsFpGenerator.__weighted_random_choice([0, 30, 60, 80], [0.1, 0.4, 0.3, 0.2]))
            availHeight = height
        else:
            availWidth = width
            availHeight = int(height) - int(XhsFpGenerator.__weighted_random_choice([30, 60, 80, 100], [0.2, 0.5, 0.2, 0.1]))

        return width, height, availWidth, availHeight

    @staticmethod
    def get_fingerprint(cookies: dict, user_agent: str) -> dict:
        cookie_string = "; ".join(f"{k}={v}" for k, v in cookies.items())

        # 窗口大小预设
        width, height, availWidth, availHeight = XhsFpGenerator.__get_width_and_height()

        # 隐身模式预设
        is_incognito_mode = XhsFpGenerator.__weighted_random_choice(['true', 'false'], [0.95, 0.05])

        vendor, renderer = XhsFpGenerator.__get_renderer_info()

        x78_y = random.randint(2350, 2450)
        fp = {
            "x1": user_agent, # ua
            "x2": "false", # navigator.webdriver # 自动化
            "x3": "zh-CN", # navigator.language # 语言 固定值
            "x4": XhsFpGenerator.__weighted_random_choice([16, 24, 30, 32], [0.05, 0.6, 0.05, 0.3]), # screen.colorDepth # 屏幕色深 24bit
            "x5": XhsFpGenerator.__weighted_random_choice([1, 2, 4, 8, 12, 16], [0.10, 0.25, 0.4, 0.2, 0.03, 0.01]), # navigator.deviceMemory # 设备内存
            "x6": "24", # screen.pixelDepth # 屏幕的像素深度（通常与 colorDepth 相同，如 24）
            "x7": f"{vendor},{renderer}", # canvas获取显卡信息
            "x8": XhsFpGenerator.__weighted_random_choice([2, 4, 6, 8, 12, 16, 24, 32], [0.1, 0.4, 0.2, 0.15, 0.08, 0.04, 0.02, 0.01]), # navigator.hardwareConcurrency 返回设备的 逻辑CPU核心数（如 12）。
            "x9": f"{width};{height}", # screen.width 和 screen.height 返回屏幕分辨率（如 2560x1440）。
            "x10": f"{availWidth};{availHeight}", # screen.availWidth 和 screen.availHeight 返回浏览器窗口的 可用显示区域大小（去掉任务栏）。
            "x11": "-480", # new Date().getTimezoneOffset() 返回本地时间与UTC的 分钟差（如 -480）。
            "x12": "Asia/Hong_Kong", # Intl.DateTimeFormat().resolvedOptions().timeZone 返回浏览器设定的 系统时区ID（如 'Asia/Hong_Kong'）
            "x13": is_incognito_mode, # window.sessionStorage 检测浏览器是否支持 会话存储（Session Storage）。 # 隐私模式是 false
            "x14": is_incognito_mode, # window.localStorage 检测浏览器是否支持 本地存储（Local Storage）。 # 隐私模式是 false
            "x15": is_incognito_mode, # window.indexedDB 检测浏览器是否支持 IndexedDB（浏览器内置的NoSQL数据库）。
            "x16": "false", # document.body.addBehavior 是 旧版IE特有方法（IE6~IE10）。 现代浏览器 不存在此方法，返回 undefined 或报错。 固定值
            "x17": "false", # window.openDatabase 检测浏览器是否支持 WebSQL（已废弃的浏览器数据库API）。现代浏览器 已移除支持，返回 undefined。 固定值
            "x18": "un", # navigator.cpuClass 是 旧版IE浏览器（IE4~IE11） 特有的属性，用于检测CPU类型（如 "x86"、"68k"、"Alpha"）。 现代浏览器 不存在此属性，返回 undefined 或 被模拟的值（如 "NOT_AVAILABLE"）。 固定值
            "x19": "Win32", # navigator.platform 返回 操作系统平台标识（如 "Win32"、"MacIntel"）。 固定值 不必多些其他系统，UA固定WINDOWS平台即可
            "x20": "", # document.querySelectorAll('[src^="chrome://"]') 检测是否存在 Chrome内部资源链接 固定值
            "x21": "PDF Viewer,Chrome PDF Viewer,Chromium PDF Viewer,Microsoft Edge PDF Viewer,WebKit built-in PDF", # navigator.plugins 返回浏览器安装的 插件列表（如PDF查看器、Flash等）。 固定值
            "x22": hashlib.md5(secrets.token_bytes(32)).hexdigest(), #
            "x23": "false", # DOM操作环境检测 固定值
            "x24": "false", # navigator.languages 是否==undefined 固定值
            "x25": "false", # screen.height / screen.availHeight 差异是否过大 固定值
            "x26": "false", # 什么都没做 固定值
            "x27": "false", # 什么都没做 固定值
            "x28": "0,false,false", # 触屏检测 maxTouchPoints ， document.createEvent("TouchEvent"); ， 'ontouchstart' in window; 固定值 
            "x29": "4,7,8", # 太长了懒得翻了 固定值
            "x30": "swf object not loaded", #  检测 Flash（SWF）对象是否成功加载 Boolean(navigator.plugins['Shockwave Flash']); 固定值
            # "x32": "0", # 暂无
            "x33": "0", # 判断是否是微信内置浏览器 固定值
            "x34": "0", # 判断渲染器是否是Brian Paul（虚拟） 固定值
            "x35": "0", # 判断是否加载 Modernizr 固定值
            "x36": f"{random.randint(1, 20)}", # 判断window.history.length 历史堆栈长度
            "x37": "0|0|0|0|0|0|0|0|0|1|0|0|0|0|0|0|0|0|1|0|0|0|0|0", # 环境监测 太长了懒得看 固定值
            "x38": "0|0|1|0|1|0|0|0|0|0|1|0|1|0|1|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0", # 环境监测 太长了懒得看 固定值
            "x39": 0, #小红书抽风这里写死成0了 f"{random.randint(1, 5)}", # localStorage.getItem('sc');  刷新一次页面 +1    1-5 随机即可 # 2025-9-7 18:17:39 注: 之前是p1 现在变成 sc
            "x40": "0", # localStorage.getItem('ptt');  但正常使用并无该值 固定0
            "x41": "0", # localStorage.getItem('pst');  但正常使用并无该值 固定0
            "x42": "3.4.3", # 所使用的Finggerprint.js版本 固定值
            "x43": "742cc32c", # 通过一张图片的hash值来检测浏览器是否被篡改 固定值
            "x44": f"{int(time.time() * 1000)}", # 当前时间戳(毫秒)
            "x45": "__SEC_CAV__1-1-1-1-1|__SEC_WSA__|", # 前端风控 SDK 的打点信息 如果有风控会是__SEC_WSA__|之类的  固定值
            "x46": "false", # navigator.__proto__.hasOwnProperty('webdriver'); 和 Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver'); // true → 风控触发
            "x47": "1|0|0|0|0|0", # 识别不同浏览器的「独占特征」  固定值
            "x48": "", # 什么都没做 固定值
            "x49": '{list:[],type:}', # 什么都没做 固定值
            "x50": "", # 固定值
            "x51": "", # 固定值
            "x52": "", # 固定值
            "x55": "380,380,360,400,380,400,420,380,400,400,360,360,440,420", # 很长 懒得看 固定即可
            "x56": f"{vendor}|{renderer}|{hashlib.md5(secrets.token_bytes(32)).hexdigest()}|35", # x7 | Fingerprint2.x64hash128(WebGLRenderingContext.getSupportedExtensions()) | WebGLRenderingContext.getSupportedExtensions().length
            "x57": cookie_string, # Cookie(略)
            "x58": "180", # document.getElementsByTagName('div') // div标签数量 固定值
            "x59": "2", # performance.getEntriesByType("resource").length   // 资源加载数量 固定值
            "x60": "63", # 风控分 固定值
            "x61": "1291", # Object.getOwnPropertyNames(window) .length  // window对象属性数量 固定值
            "x62": "2047", # HOOK检测 1,1,1,1,1,1,1,1,1,1,1  11个1(通过) 组成二进制2047 固定值
            "x63": "0", # JS VMP文件换行检测 固定值
            "x64": "0", # HOOK ToString检测 CPU核心数量检测 固定值
            "x65": "0", # 异常点分值 0 固定值
            "x66": { # navigator.userAgent
                "referer": "",
                "location": "https://www.xiaohongshu.com/explore",
                "frame": 0
            }, 
            "x67": "1|0", # 环境检测  固定值
            "x68": "0", # 固定值 
            "x69": "326|1292|30", # 暂无  第一个会变化 是 Object.keys(window)  固定值
            "x70": [ 
                "location" # Object.keys(document); 固定值
            ], 
            "x71": "true", # 环境检测  固定值
            "x72": "complete", # document.readyState 固定值
            "x73": "1191", # document.getElementsByTagName ('*') 固定值
            "x74": "0|0|0", # 环境检测  固定值
            "x75": "Google Inc.", # Navigator.vendor 固定值
            "x76": "true", # navigator.cookieEnabled
            "x77": "1|1|1|1|1|1|1|1|1|1", # 环境检测  固定值
            "x78": {
                "x": 0,
                "y": x78_y,
                "left": 0,
                "right": 290.828125,
                "bottom": x78_y+18,
                "height": 18,
                "top": x78_y,
                "width": 290.828125,
                "font": "system-ui, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\", -apple-system, \"Segoe UI\", Roboto, Ubuntu, Cantarell, \"Noto Sans\", sans-serif, BlinkMacSystemFont, \"Helvetica Neue\", Arial, \"PingFang SC\", \"PingFang TC\", \"PingFang HK\", \"Microsoft Yahei\", \"Microsoft JhengHei\""
            }, # 不好模拟 可以固定
            "x82": "_0x17a2|_0x1954", # 新建一个iframe，然后获取iframe的contentWindow对象，再获取contentWindow对象的window对象，对比差异 固定值
            "x31": "124.04347527516074", # 固定值
            "x79": "144|599565058866", # navigator.webkitTemporaryStorage.queryUsageAndQuota(used, granted)  // 随机数|599565058866 固定值
            "x53": hashlib.md5(secrets.token_bytes(32)).hexdigest(), #[REDACTED_SECRET]  if "edg/" in user_agent else [REDACTED_SECRET], # speechSynthesis.getVoices()  Fingerprint2.x64hash128 edge是235c6559af50acefe4755120d05570a0 chrome是993da9a681fd3994c9f53de11f2903b3
            "x54": "10311144241322244122", # 固定值 
            "x80": "1|[object FileSystemDirectoryHandle]", # 固定值
        }

        # 移除硬编码指纹，使用动态生成的指纹以增加真实性和反爬难度
        return fp
    
    @staticmethod
    def update_fingerprint(fp: dict, cookies: dict, url: str) -> None:
        cookie_string = "; ".join(f"{k}={v}" for k, v in cookies.items())

        fp.update({
            "x39": 0, #小红书抽风这里写死成0了 str(int(fp["x39"]) + 1), # localStorage.getItem('p1');  刷新一次页面 +1
            "x44": f"{time.time() * 1000}", # 当前时间戳(毫秒)
            "x57": cookie_string, # Cookie(略),
            "x66": { # navigator.userAgent
                "referer": "https://www.xiaohongshu.com/explore",
                "location": url,
                "frame": 0
            }
        })


path: scripts/request/web/encrypt/web_encrypt_config.ini
content: ######################################################################################################
#       加密相关
######################################################################################################

# 小红书版本号
[XHS_VERSION]
# 应用id
APP_ID = "xhs-pc-web"
# 语言版本号    vendor-dynamic.xxx.js -> 浏览器异常，请尝试更换浏览器后重试
LANGUAGE_VERSION = "4.2.6"
# 打包版本号    vendor-dynamic.xxx.js -> artifactVersion
ARTIFACT_VERSION = "5.8.0"
# 系统          vendor-dynamic.xxx.js -> /(Firefox)\/?\s*(\.?\d+(\.\d+)*)/i       (从UA里面获取的)
OS_SYSTEM = "Windows"
# vendor-dynamic.xxx.js -> .Windows / .other / "Windows" / "other"  (2025-2-4注：结合getPlatformCode(x2)观察， getPlatformCode方法中并没有判断直接等于Windows。而是走到了other分支，则返回5)
GET_PLAT_FROM_CODE = 5
# 小红书自定义的Base64编码表
BASE64_TABLE = [REDACTED_SECRET]

# x-s 加密配置
[XS_ENCRYPT]
# 小红书自定义的Base58编码表
BASE58_TABLE = [REDACTED_SECRET]
# 异或KEY
XOR_KEY = [175, 87, 43, 149, 202, 101, 178, 217, 236, 118, 187, 93, 46, 151, 203, 101, 50, 153, 204, 102, 51, 153, 204, 102, 51, 153, 204, 230, 115, 57, 156, 206, 103, 51, 25, 12, 6, 3, 1, 0, 0, 0, 0, 0, 128, 64, 32, 144, 72, 36, 18, 137, 196, 226, 113, 56, 28, 14, 7, 3, 1, 128, 64, 160, 80, 40, 20, 138, 197, 98, 49, 24, 12, 6, 131, 193, 96, 48, 152, 76, 38, 147, 201, 100, 178, 89, 172, 86, 171, 213, 234, 245, 250, 253, 126, 63, 159, 79, 39, 147, 73, 164, 210, 233, 116, 58, 157, 78, 39, 147, 73, 164, 210, 233, 244, 122, 61, 30, 143, 71, 35, 145, 72, 164]

# x-s-common 加密配置
[XSC_ENCRYPT]
#### b1相关
B1_RC4_KEY = "xhswebmplfbt"


[XB1_ENCRYPT]
# 取随机字符串次数
TRACE_ID_LENGTH = 16
# 随机字符串模板
VALID_CHARS = "abcdef0123456789"

[A1_ENCRYPT]
# 取随机字符串次数
TRACE_ID_LENGTH = 30
# 随机字符串模板
VALID_CHARS = "abcdefghijklmnopqrstuvwxyz1234567890"

[GID_ENCRYPT]
DES_KEY = "zbp30y86"
URL = "https://as.xiaohongshu.com/api/sec/v1/shield/webprofile"
DATA_PALTFORM =  "Windows"
DATA_SDK_VERSION = "4.2.6"
DATA_SVN = "2"


######################################################################################################
#       请求相关
######################################################################################################
[HEADER]
# 默认请求头 (python dict格式)
DEFAULT_HEADER = {
        "accept": "application/json, text/plain, */*",
        "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
        "content-type": "application/json;charset=UTF-8",
        "origin": "https://www.xiaohongshu.com",
        "priority": "u=1, i",
        "referer": "https://www.xiaohongshu.com/",
        "sec-ch-ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": "\"Windows\"",
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "same-site",
        "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    }




path: scripts/request/web/encrypt/xhs_diy_encode.py
content: def triplet_to_base64(a, c):
    return c[(a >> 18) & 63] + c[(a >> 12) & 63] + c[(a >> 6) & 63] + c[a & 63]

def encode_chunk(a, e, r, c):
    d = []
    for f in range(e, r, 3):
        c_val = ((a[f] << 16) & 0xff0000) + ((a[f + 1] << 8) & 0xff00) + (a[f + 2] & 0xff)
        d.append(triplet_to_base64(c_val, c))
    return ''.join(d)

def b64_encode(a, CUSTOM_BASE64_ALPHABET):
    c = CUSTOM_BASE64_ALPHABET
    r = len(a)
    d = r % 3
    f = []
    s = 16383
    u = 0
    l = r - d
    
    while u < l:
        end = min(u + s, l)
        f.append(encode_chunk(a, u, end, c))
        u += s
    
    if d == 1:
        e = a[r - 1]
        f.append(c[e >> 2] + c[(e << 4) & 63] + "==")
    elif d == 2:
        e = (a[r - 2] << 8) + a[r - 1]
        f.append(c[e >> 10] + c[(e >> 4) & 63] + c[(e << 2) & 63] + "=")
    
    return ''.join(f)

def encode_utf8(a):
    # 首先进行URL编码
    url_encoded = a
    
    result = []
    i = 0
    while i < len(url_encoded):
        c = url_encoded[i]
        if c == '%':
            # 处理%编码的字符
            hex_str = url_encoded[i+1:i+3]
            char_code = int(hex_str, 16)
            result.append(char_code)
            i += 3
        else:
            # 处理普通ASCII字符
            result.append(ord(c))
            i += 1
    return result


path: scripts/request/web/encrypt/xhs_encrypt.py
content: from request.web.encrypt.cookie.a1_and_webId import XHS_A1_And_WebId_Encrypt
from request.web.encrypt.cookie.gid_webprofile_data import XHS_Gid_Webprofile_Data_Encrypt
from request.web.encrypt.cookie.websectiga import XHS_Websectiga_Encrypt

from request.web.encrypt.header.X_B3 import XHS_XB3_Encrypt
from request.web.encrypt.header.X_S import XHS_XS_Encrypt
from request.web.encrypt.header.X_S_Common import XHS_XSC_Encrypt
from request.web.encrypt.header.X_Xray import XHS_Xray_Encrypt

from request.web.encrypt.other.XhsFpGenerator import XhsFpGenerator


class XiaoHongShu_Encrypt(XHS_A1_And_WebId_Encrypt,
                          XHS_Gid_Webprofile_Data_Encrypt,
                          XHS_Websectiga_Encrypt,

                          XHS_XB3_Encrypt,
                          XHS_XS_Encrypt,
                          XHS_XSC_Encrypt,
                          XHS_Xray_Encrypt,

                          XhsFpGenerator
                          ):
    """
    小红书加密
    """
    def __init__(self):
        XHS_A1_And_WebId_Encrypt.__init__(self)
        XHS_Gid_Webprofile_Data_Encrypt.__init__(self)
        XHS_Websectiga_Encrypt.__init__(self)

        XHS_XB3_Encrypt.__init__(self)
        XHS_XS_Encrypt.__init__(self)
        XHS_XSC_Encrypt.__init__(self)
        XHS_Xray_Encrypt.__init__(self)

        XhsFpGenerator.__init__(self)


#单例模式
xhs_encrypt = XiaoHongShu_Encrypt()

path: scripts/request/web/exceptions/session_exceptions.py
content: 
"""
    自定义小红书 session类型 异常类，用于表示 小红书网络请求方面或返回信息的异常处理。
"""

# class RequestError(Exception): ...

# web相关
class RequestMaxRetryError(Exception): ...
class OtherRequestError(Exception): ...





# respones status code相关

# 461 访问频次异常，请勿频繁操作或重启试试
class FrequencyError(Exception): ...

# 461、471 - 124 需要扫码认证
class NeedScanLogin(Exception): 
    def __init__(self, details):
        self.details = details
        super().__init__(str(self.details))
        
    def __getitem__(self, key):
        return self.details[key]

# 需要滑块
class NeedSlideVerify(Exception):
    def __init__(self, details):
        self.details = details
        super().__init__(str(self.details))

    def __getitem__(self, key):
        return self.details[key]


# 461 网络连接异常，请检查网络设置或重启试试
class NetworkError(Exception): ...
# 其他异常
class OtherStatusError(Exception): ...






# msg相关

class LoginTimeOut(Exception): ...

class MutedError(Exception): ...

class PermissionError(Exception): ...

class BannedError(Exception): ...

class UserCloseCommentAtError(Exception): ...

class TaskDeleteError(Exception): ...

class OtherMessagesError(Exception): ...

class CantCommentError(Exception): ...

path: scripts/request/web/search_config_loader.py
content: import json
import os
from datetime import datetime, timedelta
from typing import Optional, Dict, Any

class SearchConfigLoader:
    """配置加载器"""

    def __init__(self, config_path: Optional[str] = None):
        if config_path is None:
            # 默认配置文件路径
            current_dir = os.path.dirname(os.path.abspath(__file__))
            config_path = os.path.join(current_dir, 'config.json')

        self.config_path = config_path
        self.config = self._load_config()

    def _load_config(self) -> Dict[str, Any]:
        """加载配置文件"""
        if not os.path.exists(self.config_path):
            # 返回默认配置
            return self._get_default_config()

        with open(self.config_path, 'r', encoding='utf-8') as f:
            return json.load(f)

    def _get_default_config(self) -> Dict[str, Any]:
        """获取默认配置"""
        return {
            "web_session": {
                "value": None,
                "description": "小红书web_session cookie值(必需)"
            },
            "search": {
                "default_page": 1,
                "default_page_size": 20,
                "default_sort": "general",
                "default_note_type": 0,
                "post_time_filter": {
                    "enabled": False,
                    "hours": 24,
                    "description": "帖子发布时间过滤:只返回过去N小时内发布的帖子"
                }
            },
            "proxy": {
                "enabled": False,
                "url": None,
                "description": "代理设置"
            },
            "rate_limit": {
                "min_interval_seconds": 1,
                "max_retries": 5,
                "description": "请求频率限制"
            },
            "onboarding_complete": False,
            "description": "Onboarding是否已完成"
        }

    def get_web_session(self) -> Optional[str]:
        """获取web_session配置"""
        web_session_config = self.config.get('web_session', {})
        return web_session_config.get('value')

    def get_search_defaults(self) -> Dict[str, Any]:
        """获取搜索默认参数"""
        search_config = self.config.get('search', {})
        return {
            'page': search_config.get('default_page', 1),
            'page_size': search_config.get('default_page_size', 20),
            'sort': search_config.get('default_sort', 'general'),
            'note_type': search_config.get('default_note_type', 0)
        }

    def get_post_time_filter(self) -> Dict[str, Any]:
        """获取帖子发布时间过滤配置"""
        return self.config.get('search', {}).get('post_time_filter', {
            'enabled': False,
            'hours': 24
        })

    def get_proxy_config(self) -> Dict[str, Any]:
        """获取代理配置"""
        return self.config.get('proxy', {})

    def get_rate_limit(self) -> Dict[str, Any]:
        """获取频率限制配置"""
        return self.config.get('rate_limit', {})

    def reload_config(self):
        """重新加载配置"""
        self.config = self._load_config()

    def update_config(self, updates: Dict[str, Any]) -> bool:
        """更新配置文件

        Args:
            updates: 配置更新字典，支持嵌套更新
                    例如: {'search': {'default_page_size': 20}}

        Returns:
            bool: 更新是否成功
        """
        try:
            # 递归更新配置
            def deep_update(original, updates_dict):
                for key, value in updates_dict.items():
                    if key in original and isinstance(original[key], dict) and isinstance(value, dict):
                        deep_update(original[key], value)
                    else:
                        original[key] = value

            deep_update(self.config, updates)

            # 保存到文件
            with open(self.config_path, 'w', encoding='utf-8') as f:
                json.dump(self.config, f, indent=2, ensure_ascii=False)

            return True
        except Exception as e:
            print(f"更新配置失败: {e}")
            return False

    @staticmethod
    def get_default_config() -> Dict[str, Any]:
        """获取默认配置(静态方法)"""
        return {
            "web_session": {
                "value": None,
                "description": "小红书web_session cookie值(必需)"
            },
            "search": {
                "default_page": 1,
                "default_page_size": 20,
                "default_sort": "general",
                "default_note_type": 0,
                "post_time_filter": {
                    "enabled": False,
                    "hours": 24,
                    "description": "帖子发布时间过滤:只返回过去N小时内发布的帖子"
                }
            },
            "proxy": {
                "enabled": False,
                "url": None,
                "description": "代理设置"
            },
            "rate_limit": {
                "min_interval_seconds": 1,
                "max_retries": 5,
                "description": "请求频率限制"
            },
            "onboarding_complete": False,
            "description": "Onboarding是否已完成"
        }

# 单例模式
_config_loader = None

def get_search_config() -> SearchConfigLoader:
    """获取配置加载器单例"""
    global _config_loader
    if _config_loader is None:
        _config_loader = SearchConfigLoader()
    return _config_loader

def save_config(updates: Dict[str, Any]) -> bool:
    """保存配置更新(便捷函数)"""
    config_loader = get_search_config()
    return config_loader.update_config(updates)

def load_config() -> Dict[str, Any]:
    """加载当前配置(便捷函数)"""
    config_loader = get_search_config()
    return config_loader.config


path: scripts/request/web/xhs_session.py
content: import aiohttp
import urllib.parse
import json
import time
import asyncio
import uuid
from loguru import logger

from request.web.encrypt.config import xhs_config
from request.web.encrypt.xhs_encrypt import xhs_encrypt
from request.web.exceptions import session_exceptions
from request.web.apis import APIModule
from urllib.parse import urlparse
from getuseragent import UserAgent


class XHS_Session:
    def __init__(self, web_session: str = None, proxy_url: str = "", did: str = None, sid: str = None, *args, **kwargs):
        self.apis = APIModule(self)
        self.__xhs_encrypt = xhs_encrypt
        self.__web_session = web_session
        self.__fp = {}

        self._proxy_url = proxy_url

        self._did = did if did else str(uuid.uuid4())
        self._sid = sid
        
        self._session = None
        self._connector = None
        
    @property  
    def headers(self):
        return getattr(self._session, 'headers', {}) if self._session else {}
        
    @property
    def cookies(self):
        if not self._session:
            return {}
        return {cookie.key: cookie.value for cookie in self._session.cookie_jar}

    def get_fp(self) -> dict:
        return self.__fp


    def __init_headers(self) -> None:
        initial_headers = xhs_config.get('HEADER', 'DEFAULT_HEADER').copy()
        ua = UserAgent("chrome+edge+firefox")
        initial_headers['User-Agent'] = ua.Random()
        if self._session:
            self._session.headers.update(initial_headers)

    async def __init_cookies(self) -> None:
        a1, webId = self.__xhs_encrypt.encrypt_cookie_a1_and_webId()
        
        initial_cookies = {
            'a1': a1,
            'webId': webId,
            'webBuild': xhs_config.get('XHS_VERSION', 'ARTIFACT_VERSION'),
            'xsecappid': xhs_config.get('XHS_VERSION', 'APP_ID'),
            'loadts': str(int(time.time())),
            'abRequestId': uuid.uuid4()
        }

        for key, value in initial_cookies.items():
            self._session.cookie_jar.update_cookies({key: str(value)})

        await self.__set_websectiga_and_secPoisonId()

        cookies_dict = {cookie.key: cookie.value for cookie in self._session.cookie_jar}
        self.__fp = self.__xhs_encrypt.get_fingerprint(
            cookies_dict, 
            self._session.headers.get('User-Agent')
        )


        await self.__set_gid_and_acw_tc()
        
        if self.__web_session is None:
            await self.__set_web_session()
        else:
            self._session.cookie_jar.update_cookies({"web_session": self.__web_session})

    async def _initialize(self) -> None:
        """初始化会话"""
        self._connector = await self._create_proxy_connector()


        timeout = aiohttp.ClientTimeout(total=30)
        
        self._session = aiohttp.ClientSession(
            connector=self._connector,
            timeout=timeout,
            headers=xhs_config.get('HEADER', 'DEFAULT_HEADER').copy(),
            trust_env=True
        )
        
        self.__init_headers()
        await self.__init_cookies()

        # 如果填写了sid, did, 则尝试自动登录
        if self._sid and self._did:
            await self.apis.auth.scan_login()


    async def _create_proxy_connector(self):
        """代理连接器创建"""
        if not self._proxy_url:
            return None
            
        if self._proxy_url.startswith('socks'):
            try:
                from aiohttp_socks import SocksConnector
                return SocksConnector.from_url(self._proxy_url)
            except ImportError:
                logger.warning("aiohttp-socks库未安装，SOCKS5代理将不生效")
                return aiohttp.TCPConnector()
        else:
            return aiohttp.TCPConnector()

    async def __set_websectiga_and_secPoisonId(self) -> None:
        url = "https://as.xiaohongshu.com/api/sec/v1/scripting"
        data = {"callFrom": "web", "callback": "seccallback"}

        res = await self.request(method="post",
                                 url=url,
                                 data=data,
                                 need_encrypt=False)

        res_json = await res.json()

        self._session.cookie_jar.update_cookies({
            "websectiga": self.__xhs_encrypt.gen_websectiga(res_json.get('data').get('data')),
            "sec_poison_id": res_json.get('data').get('secPoisonId'),
        })

    async def __set_gid_and_acw_tc(self) -> None:
        """  注意:xhs两个acw_tc, 他们的域不一样，一个是www.xiaohongshu.com 一个是 edith.xiaohongshu.com
            但由于同名会引发异常，所以不要轻易的访问www.xiaohongshu.com
            API协议请求通常只需要edith.xiaohongshu.com的acw_tc即可 """

        url, data = self.__xhs_encrypt.gen_gid_webprofile_data(self.__fp)

        res = await self.request(method="post",
                             url=url,
                             data=data)


    async def __set_web_session(self) -> None:
        url = "https://edith.xiaohongshu.com/api/sns/web/v1/login/activate"
        data = {}

        await self.request(method="post",
                           url=url,
                           data=data)


    def __request_encrypt(self, url, params, data) -> None:
        """加密请求"""
        cookies_dict = {cookie.key: cookie.value for cookie in self._session.cookie_jar}
        a1 = cookies_dict.get('a1', '')
        if not a1:
            return
            
        loadts = str(int(time.time() * 1000))
        self._session.cookie_jar.update_cookies({"loadts": str(loadts)})
        
        xray = self.__xhs_encrypt.encrypt_headers_xray()
        self._session.headers['x-xray-traceid'] = str(xray)
        
        xb3 = self.__xhs_encrypt.encrypt_header_xb3()
        self._session.headers['x-b3-traceid'] = str(xb3)
        
        url_path = urlparse(url).path
        xs = self.__xhs_encrypt.encrypt_headers_xs(
            a1, int(loadts), url_path, params, data
        )
        self._session.headers['x-s'] = str(xs)

        self.__xhs_encrypt.update_fingerprint(self.__fp, cookies_dict, url)
        xsc = self.__xhs_encrypt.encrypt_headers_xsc(a1, self.__fp)
        self._session.headers['x-s-common'] = str(xsc)
        
        xt = int(time.time() * 1000)
        self._session.headers['x-t'] = str(xt)

    async def __handle_xhs_response_exceptions(self, response: aiohttp.ClientResponse, request_info: str):
        """异常处理"""
        try:
            res_json = await response.json()
            res_text = await response.text()
        except Exception:
            res_json = {}
            res_text = ""

        status_code = response.status
        res_msg = res_json.get('msg', '')
        
        cookies_dict = {cookie.key: cookie.value for cookie in self._session.cookie_jar}
        web_session = cookies_dict.get('web_session')
                
        logger_info = f"{status_code} | {response.request_info.method} | {request_info}"

        if status_code != 200:
            if status_code in [461, 471]:
                verifyUuid = response.headers.get("Verifyuuid", "")
                verifyType = response.headers.get("Verifytype", "")
                VerifyCode = int(status_code)

                if str(verifyType) == "124":
                    logger.error(f"需要扫描二维码 | {logger_info}")
                    raise session_exceptions.NeedScanLogin({
                        "verifyUuid": verifyUuid, "verifyType": verifyType,
                        "VerifyCode": VerifyCode, "msg": res_msg,
                    })
                if str(verifyType) == "102":
                    logger.error(f"需要通过滑块旋转验证码 | {logger_info}")
                    raise session_exceptions.NeedSlideVerify({
                        "verifyUuid": verifyUuid, "verifyType": verifyType,
                        "VerifyCode": VerifyCode, "msg": res_msg,
                    })
                logger.error(f"其他安全相关异常 | {res_text} | {logger_info}")
                raise session_exceptions.OtherStatusError("461异常")

            elif status_code == 406:
                logger.error(f"406异常 | {logger_info}")
                raise session_exceptions.OtherStatusError("406异常")

            else:
                logger.error(f"其他异常状态码 | {logger_info}")
                raise session_exceptions.OtherStatusError(f"状态码: {status_code}")

        if res_msg in ["成功", 'ok']:
            logger.info(f"成功 | {logger_info}")
            return response

        # 其他异常匹配
        msg_lower = str(res_msg).lower()
        res_text_lower = str(res_text).lower()

        if "禁言" in msg_lower or "被禁言" in msg_lower:
            logger.warning(f"禁言 | {web_session} | {res_msg} | {logger_info}")
            raise session_exceptions.MutedError(res_msg)

        elif "登录已过期" in res_text_lower or "登录超时" in msg_lower:
            logger.warning(f"掉线 | {web_session} | web_session 登录超时 | {logger_info}")
            raise session_exceptions.LoginTimeOut(res_msg)

        elif "删除" in res_text_lower:
            logger.warning(f"笔记/评论被删除 | {logger_info}")
            raise session_exceptions.TaskDeleteError(res_msg)

        elif "无权限访问" in msg_lower:
            logger.warning(f"过期 | {web_session} | web_session 没有权限访问 | {logger_info}")
            raise session_exceptions.PermissionError(res_msg)

        elif "违规情形" in msg_lower or "被封号" in msg_lower or "封号" in msg_lower:
            logger.warning(f"封号 | {web_session} | {res_msg} | {logger_info}")
            raise session_exceptions.BannedError(res_msg)

        elif "用户已关闭评论艾特" in msg_lower:
            logger.warning(f"用户已关闭评论艾特 | {logger_info}")
            raise session_exceptions.UserCloseCommentAtError(res_msg)

        elif "对方设置" in msg_lower or "无法发布评论" in msg_lower:
            logger.warning(f"对方设置你无法评论 | {logger_info}")
            raise session_exceptions.CantCommentError(res_msg)

        elif "blockedps" in res_text_lower:
            logger.warning(f"封号 | {web_session} | blockedPs | {logger_info}")
            raise session_exceptions.BannedError(res_msg)

        else:
            logger.error(f"未捕获异常 | {logger_info} | {res_json}")
            raise session_exceptions.OtherMessagesError(res_text)

    async def request(self,
                      method: str,
                      url: str,
                      max_retries=5,
                      retry_delay=0.1,
                      is_catch_response_exception=True,
                      need_encrypt=True,
                      update_headers=None,
                      **kwargs) -> aiohttp.ClientResponse:
        """统一请求方法 - 确保资源正确管理"""
        # 保存原始参数用于重试
        original_params = dict(
            method=method, url=url, max_retries=max_retries, retry_delay=retry_delay,
            is_catch_response_exception=is_catch_response_exception,
            need_encrypt=need_encrypt,
            update_headers=update_headers,
            kwargs=kwargs
        )

        params = kwargs.get('params')
        data = kwargs.get('data')
        
        # 处理数据
        request_data = None
        if data is not None and data != 'null':
            request_data = json.dumps(data, separators=(',', ':'))

        # 构建URL
        full_url = url
        if params:
            full_url += '?' + urllib.parse.urlencode(params).replace('%2C', ',')

        # 日志信息预处理（还原原始格式）
        data_string = f" | data:{json.dumps(request_data)[:200]}" if request_data else ""
        params_string = f" | params:{json.dumps(params)[:200]}" if params else ""
        request_info = f"{full_url}{data_string}{params_string}"

        # 处理加密相关
        if need_encrypt:
            self.__request_encrypt(url, params, data if data != 'null' else None)

        # 头信息
        headers = dict(self._session.headers)
        if update_headers:
            headers.update(update_headers)

        # 代理设置
        proxy = None
        if self._proxy_url:
            if not self._proxy_url.startswith('socks'):
                proxy = self._proxy_url

        # 重试逻辑
        for attempt in range(max_retries):
            try:
                response = await self._session.request(
                    method=method.upper(),
                    url=full_url,
                    data=request_data,
                    headers=headers,
                    proxy=proxy,
                    **{k: v for k, v in kwargs.items() if k not in ['params', 'data']}
                )

                if is_catch_response_exception:
                    return await self.__handle_xhs_response_exceptions(response, request_info)
                return response
                    
            except aiohttp.ClientError as e:
                logger.warning(f"尝试第 {attempt + 1}/{max_retries} 次时出错: {request_info} - {e}")
                if attempt < max_retries - 1:
                    await asyncio.sleep(retry_delay)
                else:
                    logger.error(f"重试 {max_retries} 次后仍然失败: {request_info}")
                    logger.exception(e)
                    raise session_exceptions.RequestMaxRetryError(str(e))

            except session_exceptions.NeedScanLogin as e:
                if self._sid is None:
                    logger.error(f"需要扫描二维码, 但由于没有设置app的sid, 无法自动通过: {request_info}")
                    raise

                await self.apis.auth.pass_scan_124(
                    verifyType=e['verifyType'],
                    verifyBiz=e['VerifyCode'],
                    verifyUuid=e['verifyUuid'],
                    sourceSite=f"https://www.xiaohongshu.com/explore",
                    did=self._did,
                    sid=self._sid
                )
                logger.success(f"扫码过验证码成功, 重新提交该次请求 | {request_info}")



                return await self.request(
                        original_params['method'],
                        original_params['url'],
                        original_params['max_retries'],
                        original_params['retry_delay'],
                        original_params['is_catch_response_exception'],
                        original_params['need_encrypt'],
                        original_params['update_headers'],
                        **original_params['kwargs']
                    )

            except Exception as e:
                logger.error(f"请求异常: {request_info}")
                logger.exception(e)
                raise

    async def close_session(self):
        """安全关闭会话和连接器"""
        if self._session:
            # 确保关闭前完成所有未完成的response
            if hasattr(self._session, '_connector'):
                # 关闭连接器并清理待处理连接
                connector = self._session._connector
                if connector:
                    connector.close()  # 强制关闭
                    connector._cleanup() if hasattr(connector, '_cleanup') else None

            await self._session.close()
            self._session = None

        # 强制cleanup
        if self._connector:
            if hasattr(self._connector, '_cleanup'):
                self._connector._cleanup()
            self._connector = None

        # 确保完成
        await asyncio.sleep(0.01)  # 给时间完成关闭




async def create_xhs_session(
    web_session: str = None,
    proxy: str = None,
    did: str = None,
    sid: str = None,
) -> XHS_Session:
    """创建XHS_Session实例

    Args:
        web_session: 小红书web_session cookie值。如果未提供，将自动从config.json读取
        proxy: 代理地址
        did: 设备ID
        sid: 会话ID

    Returns:
        XHS_Session实例
    """
    # 如果未提供web_session，从配置文件读取
    if web_session is None:
        from request.web.search_config_loader import get_search_config
        config = get_search_config()
        web_session = config.get_web_session()

        if web_session is None:
            logger.warning("未配置web_session！请在config.json中配置web_session.value，或手动传入web_session参数")
        else:
            logger.info("从配置文件加载web_session成功")

    session = XHS_Session(web_session, proxy_url=proxy, did=did, sid=sid)
    await session._initialize()
    return session

path: scripts/units/base_response.py
content: from typing import Generic, TypeVar, Optional
from pydantic import BaseModel, Field

T = TypeVar('T')

class BaseResponse(BaseModel, Generic[T]):
    code: int = Field(..., description="状态码")
    message: str = Field(..., description="提示信息")
    info: Optional[str] = Field(None, description="附加信息")
    data: Optional[T] = Field(None, description="业务数据")

    class Config:
        json_schema_extra = {
            "example": {
                "code": 200,
                "message": "成功",
                "info": "操作成功完成",
                "data": {"key": "value"}
            }
        }

path: scripts/units/fuck_reverse_crypto/bitwise_operations.py
content: 
def unsigned_left_shift(value, shift):
    """无符号左移"""
    return (value << shift) & 0xFFFFFFFF


def unsigned_right_shift(value, shift):
    """无符号右移"""
    return (value & 0xFFFFFFFF) >> shift

path: scripts/units/fuck_reverse_crypto/encoding.py
content: def base64_encode(data: bytes, custom_alphabet: str = None) -> bytes:
    """Base64 编码
    
    将输入的字节序列编码为Base64字符串。可选使用自定义字母表进行编码。
    
    Args:
        str: 要编码的字节序列
        custom_alphabet: 可选的自定义Base64字母表，长度必须为64个字符。
                        默认使用标准Base64字母表 (A-Z, a-z, 0-9, +/)
    
    Returns:
        编码后的Base64字符串
    """
    # 标准 Base64 编码表 (64个字符)
    STANDARD_ALPHABET = b[REDACTED_SECRET]
    
    # 如果传入了自定义编码表，使用它
    ALPHABET = custom_alphabet.encode() if custom_alphabet else STANDARD_ALPHABET
    ENCODE_TRANS = bytes.maketrans(STANDARD_ALPHABET, ALPHABET)
    
    # 标准 Base64 编码
    from base64 import b64encode
    return b64encode(data).translate(ENCODE_TRANS)






def base64_decode(encoded_str: str | bytes, custom_alphabet: str = None) -> bytes:
    """Base64 解码
    
    将Base64编码的字符串解码为原始字节序列。如果编码时使用了自定义字母表，
    解码时需要提供相同的字母表。
    
    Args:
        encoded_str: Base64编码的字符串
        custom_alphabet: 解码使用的自定义Base64字母表，必须与编码时使用的相同。
                        默认使用标准Base64字母表 (A-Z, a-z, 0-9, +/)
    
    Returns:
        解码后的字节序列
        
    Raises:
        binascii.Error: 如果输入的Base64字符串格式不正确
    """
    # 标准 Base64 编码表 (64个字符)
    standard_alphabet = b[REDACTED_SECRET]
    
    # 如果传入了自定义编码表，使用它
    alphabet = custom_alphabet.encode() if custom_alphabet else standard_alphabet
    
    # 将自定义编码转换为标准 Base64 编码表
    decode_trans = bytes.maketrans(alphabet, standard_alphabet)
    
    # 先将自定义编码转换为标准编码，然后再解码
    if type(encoded_str) == str:
        encoded = encoded_str.encode()
    else:
        encoded = encoded_str
    
    from base64 import b64decode
    decoded_str = b64decode(encoded.translate(decode_trans))
    
    return decoded_str

def hex_encode(data: bytes) -> str:
    """Hex 编码"""
    return data.hex()

def hex_decode(encoded_data: str) -> bytes:
    """Hex 解码"""
    return bytes.fromhex(encoded_data)

def url_encode(data: str) -> str:
    """URL 编码"""
    from urllib.parse import quote
    return quote(data)

def url_decode(encoded_data: str) -> str:
    """URL 解码"""
    from urllib.parse import unquote
    return unquote(encoded_data)


path: scripts/units/fuck_reverse_crypto/hash_functions.py
content: import hashlib
import zlib


def md5_encode(data: str) -> str:
    return hashlib.md5(data.encode()).hexdigest()

def sha256_encode(data: str) -> str:
    return hashlib.sha256(data.encode()).hexdigest()

def sha1_encode(data: str) -> str:
    return hashlib.sha1(data.encode()).hexdigest()

def sha256_encode(data: str) -> str:
    return hashlib.sha256(data.encode()).hexdigest()

def crc32_encode(data: str) -> str:
    return zlib.crc32(data.encode())


path: scripts/units/singleton.py
content: def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

path: _meta.json
content: ownerId: kn791v855cqhsz95yh61dsqqx980ey8y
slug: xiaohongshutools
version: 0.1.0
publishedAt: 1770095284943

path: README.md
content: # Follow-XHS

小红书笔记搜索和内容获取工具，支持笔记搜索、详情获取，可配合 AI 生成结构化分析报告。

## 功能

- **笔记搜索** - 按关键词搜索小红书笔记，支持分页、排序、时间过滤
- **笔记详情** - 获取指定笔记的完整内容
- **用户笔记** - 获取指定用户发布的笔记列表
- **用户信息** - 获取当前登录用户信息
- **请求加密** - 自动处理小红书 Web 端请求签名和 Cookie 生成
- **代理支持** - 支持 HTTP/SOCKS5 代理
- **频率限制** - 内置请求频率控制和重试机制

## 项目结构

```
├── prompts/
│   └── summary_prompt.md         # AI 分析报告提示词
├── scripts/
│   ├── request/
│   │   └── web/
│   │       ├── apis/             # API 接口封装
│   │       │   ├── auth.py       # 认证相关
│   │       │   └── note.py       # 笔记搜索与详情
│   │       ├── encrypt/          # 请求加密模块
│   │       │   ├── config.py     # 加密配置加载
│   │       │   ├── xhs_encrypt.py # 加密入口
│   │       │   ├── cookie/       # Cookie 生成
│   │       │   ├── header/       # 请求头签名
│   │       │   └── other/        # 指纹生成等
│   │       ├── exceptions/       # 异常定义
│   │       ├── config.json       # 运行时配置（需自行创建）
│   │       ├── config.example.json # 配置模板
│   │       ├── search_config_loader.py # 配置加载器
│   │       └── xhs_session.py    # 会话管理
│   └── units/                    # 工具模块
├── skill.md                      # Skill 定义文件
└── requirements.txt
```

## 安装

```bash
# 克隆项目
git clone https://github.com/YOUR_USERNAME/follow-xhs.git
cd follow-xhs

# 安装依赖
pip install -r requirements.txt
```

## 配置

1. 复制配置模板：

```bash
cp scripts/request/web/config.example.json scripts/request/web/config.json
```

2. 编辑 `config.json`，填入你的 `web_session` cookie 值：

```json
{
  "web_session": {
    "value": "你的web_session值"
  }
}
```

> **获取 web_session**：浏览器登录 [小红书](https://www.xiaohongshu.com)，打开开发者工具 (F12) -> Application -> Cookies，找到 `web_session` 的值。

### 可选配置

- **代理** - 设置 `proxy.enabled` 为 `true` 并填入代理地址
- **搜索参数** - 调整 `search` 下的默认分页大小、排序方式等
- **频率限制** - 修改 `rate_limit` 下的请求间隔和重试次数

## 依赖

- Python 3.10+
- aiohttp
- loguru
- pycryptodome
- getuseragent

## 免责声明

本项目仅供学习交流使用，请勿用于商业用途或违法活动。使用本工具的一切后果由使用者自行承担。

## License

MIT


path: requirements.txt
content: aiohttp
loguru
pycryptodome
getuseragent
pydantic


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)