1 line
52 KiB
JSON
1 line
52 KiB
JSON
{"repo": "bcefghj/interview-experience-spider", "files": {"config.example.json": {"79a681316ee347dd00245cf0c99550d0a96ca4f3": {"t": 1785603940.1530845, "n": 1040, "h": "5de10c3d4fee6689", "c": "{\n \"_comment\": \"复制此文件为 config.json,填入你的配置。config.json 已加入 .gitignore,不会被上传。\",\n\n \"nowcoder\": {\n \"queries\": [\n \"字节飞书后端实习\",\n \"字节飞书 golang 面经\",\n \"字节飞书服务端实习面经\",\n \"字节飞书暑期实习面经\"\n ],\n \"max_pages\": 3,\n \"delay_seconds\": 0.8\n },\n\n \"xiaohongshu\": {\n \"enabled\": true,\n \"_cookie_help\": \"见 docs/get-xhs-cookies.md,Cookie 约1天过期\",\n \"cookies\": {\n \"a1\": \"填入你的 a1 cookie 值\",\n \"web_session\": \"填入你的 web_session cookie 值\",\n \"webId\": \"填入你的 webId cookie 值\",\n \"gid\": \"填入你的 gid cookie 值\"\n },\n \"queries\": [\n \"字节飞书面试\",\n \"字节飞书后端面经\",\n \"字节飞书实习 golang\"\n ],\n \"max_pages\": 3,\n \"delay_seconds\": 0.8\n },\n\n \"filter\": {\n \"mode\": \"all\",\n \"_mode_options\": \"all = 全部保留(去掉运营销售等) | backend = 只保留后端/Go相关\",\n \"backend_keywords\": [\"后端\", \"golang\", \"go语言\", \"服务端\", \"backend\", \"全栈\"],\n \"exclude_keywords\": [\"运营\", \"销售\", \"ux设计\", \"产品经理\", \"数据分析\", \"市场营销\"]\n },\n\n \"output\": {\n \"dir\": \"./output\",\n \"title\": \"面试备考手册\",\n \"formats\": [\"md\", \"html\"],\n \"max_content_length\": 2000\n }\n}\n"}}, "generate_report.py": {"c984b42597f21f331271aaa47f8a19b17fcf9e5c": {"t": 1785603941.445472, "n": 13706, "h": "55b5d6e43a7b4f5e", "c": "#!/usr/bin/env python3\n\"\"\"\nMD/HTML 报告生成器\n用法:\n python3 generate_report.py # 使用 config.json\n python3 generate_report.py --nc output/nc.json --xhs output/xhs.json\n python3 generate_report.py --title \"字节飞书备考\" --filter backend\n\"\"\"\nimport json, re, os, sys, argparse\n\ntry:\n import markdown as md_lib\nexcept ImportError:\n print(\"❌ 缺少依赖: pip3 install markdown\")\n sys.exit(1)\n\nBACKEND_KWS = [\"后端\", \"golang\", \"go语言\", \"服务端\", \"backend\", \"全栈\", \"开发工程师\"]\nEXCLUDE_KWS = [\"运营\", \"销售\", \"ux设计\", \"产品经理\", \"数据分析\", \"市场营销\", \"人力资源\"]\n\n\ndef filter_posts(posts: list, mode: str = \"all\") -> list:\n if mode == \"backend\":\n return [d for d in posts if any(\n kw in (d.get(\"title\", \"\") + d.get(\"content\", \"\")).lower()\n for kw in BACKEND_KWS\n )]\n else: # all\n return [d for d in posts if not any(\n kw in (d.get(\"title\", \"\") + d.get(\"content\", \"\")).lower()\n for kw in EXCLUDE_KWS\n )]\n\n\ndef build_md(nc_posts: list, xhs_posts: list, title: str,\n max_len: int = 2000) -> str:\n lines = []\n L = lines.append\n\n nc_cnt = len(nc_posts)\n xhs_cnt = len(xhs_posts)\n\n L(f\"# {title}\")\n L(\"\")\n L(f\"> 牛客网 **{nc_cnt}** 篇 + 小红书 **{xhs_cnt}** 篇,共 **{nc_cnt + xhs_cnt}** 篇真实面经\")\n L(\"\")\n L(\"---\")\n L(\"\")\n\n # 高频考点\n L(\"## 第一部分:高频考点速查\")\n L(\"\")\n\n topics = [\n (\"Golang / Go\", [\n \"GMP 调度模型(G/M/P 的含义 + work stealing)\",\n \"Goroutine vs 线程的区别\",\n \"Channel 底层实现(hchan 结构 + 阻塞原理)\",\n \"map 并发不安全 → sync.Map / Mutex 方案\",\n \"GC 三色标记法 + 写屏障\",\n \"slice 扩容规则(<256 翻倍,>256 增长 1.25)\",\n \"defer LIFO + 闭包陷阱\",\n \"interface 底层 iface/eface\",\n \"内存逃逸分析\",\n ]),\n (\"MySQL\", [\n \"B+ 树索引结构 + 为什么不用 B 树\",\n \"聚簇索引 vs 非聚簇索引(回表/覆盖索引)\",\n \"联合索引最左前缀 + 索引失效场景\",\n \"MVCC 原理(undo log + read view)\",\n \"四种隔离级别 + 幻读解决方案\",\n \"redo log / undo log / bin log 区别\",\n \"行锁 / 表锁 / 间隙锁 / Next-Key Lock\",\n \"慢 SQL 排查:explain + 索引优化\",\n ]),\n (\"Redis\", [\n \"为什么快:单线程 + IO多路复用 + 内存操作\",\n \"五种数据结构底层实现\",\n \"缓存穿透/击穿/雪崩 三问 ★★★\",\n \"RDB vs AOF 持久化\",\n \"分布式锁:SET NX EX + Redlock\",\n \"内存淘汰:LRU vs LFU\",\n \"热 Key / 大 Key 处理\",\n \"集群:主从/哨兵/Cluster\",\n ]),\n (\"计算机网络\", [\n \"TCP 三次握手/四次挥手 ★★★\",\n \"TIME_WAIT(2MSL 原因)\",\n \"TCP 可靠传输:序号/确认/重传/流控/拥塞控制\",\n \"HTTP 1.0/1.1/2.0/3.0 区别\",\n \"HTTPS TLS 握手过程\",\n \"URL → 页面全流程 ★★★\",\n \"DNS 解析(UDP 53 端口)\",\n ]),\n (\"操作系统\", [\n \"进程/线程/协程区别\",\n \"IO 多路复用:select/poll/epoll 区别\",\n \"虚拟内存 + 缺页中断\",\n \"死锁四条件 + 预防\",\n ]),\n (\"系统设计\", [\n \"秒杀系统设计(防超卖)★★★\",\n \"分布式 ID(雪花算法)\",\n \"限流:令牌桶/漏桶/滑动窗口\",\n \"短链服务设计\",\n \"敏感词过滤(Trie)\",\n ]),\n (\"算法(手撕)\", [\n \"★★★ LC3 无重复最长子串(滑动窗口)\",\n \"★★★ LC128 最长连续序列(HashSet O(n))\",\n \"★★★ LC25 K个一组翻转链表\",\n \"★★★ LC146 LRU 缓存\",\n \"★★ LC102 二叉树层序遍历\",\n \"★★ LC76 最小覆盖子串\",\n ]),\n ]\n\n for i, (topic, qs) in enumerate(topics):\n L(f\"### {i+1}. {topic}\")\n L(\"\")\n for q in qs:\n L(f\"- {q}\")\n L(\"\")\n\n L(\"---\")\n L(\"\")\n\n # 牛客面经\n L(f\"## 第二部分:牛客网面经(共 {nc_cnt} 篇,节选30篇原文)\")\n L(\"\")\n for i, d in enumerate(nc_posts[:30]):\n title = d.get(\"title\", \"无标题\")\n url = d.get(\"url\", \"\")\n content = d.get(\"content\", \"\").strip()\n if len(content) > max_len:\n content = content[:max_len] + \"\\n\\n...(内容截断,完整见原链接)\"\n L(f\"### {i+1}. {title}\")\n L(\"\")\n if url:\n L(f\"> 来源:[{url}]({url})\")\n L(\"\")\n L(content)\n L(\"\")\n L(\"---\")\n L(\"\")\n\n # 小红书面经\n L(f\"## 第三部分:小红书面经(共 {xhs_cnt} 篇,节选30篇原文)\")\n L(\"\")\n for i, d in enumerate(xhs_posts[:30]):\n title = d.get(\"title\", \"无标题\")\n url = d.get(\"url\", \"\")\n content = d.get(\"content\", \"\").strip()\n if len(content) > max_len:\n content = content[:max_len] + \"\\n\\n...(内容截断)\"\n L(f\"### {i+1}. {title}\")\n L(\"\")\n if url:\n L(f\"> 来源:[小红书]({url})\")\n L(\"\")\n L(content)\n L(\"\")\n L(\"---\")\n L(\"\")\n\n # 附录\n L(\"## 附录:全部面经索引\")\n L(\"\")\n L(f\"### 牛客网({nc_cnt} 篇)\")\n L(\"\")\n for i, d in enumerate(nc_posts):\n L(f\"{i+1}. [{d.get('title','无标题')}]({d.get('url','')})\")\n L(\"\")\n L(f\"### 小红书({xhs_cnt} 篇)\")\n L(\"\")\n for i, d in enumerate(xhs_posts):\n L(f\"{i+1}. [{d.get('title','无标题')}]({d.get('url','')})\")\n\n return \"\\n\".join(lines)\n\n\n_CSS = \"\"\"\n:root{--bg:#0f1117;--sb:#161b22;--card:#1c2128;--bdr:#30363d;--tx:#e6edf3;--mt:#8b949e;--ac:#58a6ff;--ac2:#3fb950;--ac3:#d29922;}\n*{box-sizing:border-box;margin:0;padding:0;}\nbody{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--tx);display:flex;min-height:100vh;font-size:15px;line-height:1.7;}\n#sb{width:270px;min-width:270px;background:var(--sb);border-right:1px solid var(--bdr);position:fixed;top:0;left:0;bottom:0;overflow-y:auto;padding:16px 0;z-index:100;}\n#sbh{padding:0 14px 14px;border-bottom:1px solid var(--bdr);margin-bottom:6px;}\n#sbh h2{font-size:12px;color:var(--mt);text-transform:uppercase;letter-spacing:.8px;}\n#sb ul{list-style:none;}\n#sb a{display:block;padding:5px 14px;color:var(--mt);text-decoration:none;font-size:13px;border-left:3px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .15s;}\n#sb a:hover,#sb a.on{color:var(--ac);background:rgba(88,166,255,.07);border-left-color:var(--ac);}\n.h2a{font-weight:600;color:var(--tx)!important;}\n.h3a{padding-left:26px!important;font-size:12px!important;}\n#mn{margin-left:270px;flex:1;max-width:940px;padding:36px 48px;}\n.hero{background:linear-gradient(135deg,#1a2a4a,#0d1b2e);border:1px solid #1f6feb;border-radius:12px;padding:22px 26px;margin-bottom:28px;}\n.hero-t{font-size:20px;color:var(--ac);font-weight:700;margin-bottom:6px;}\n.badge{display:inline-block;padding:3px 9px;border-radius:20px;font-size:12px;font-weight:600;margin:3px 3px 0 0;}\n.bb{background:rgba(88,166,255,.15);color:#58a6ff;border:1px solid rgba(88,166,255,.3);}\n.bg{background:rgba(63,185,80,.15);color:#3fb950;border:1px solid rgba(63,185,80,.3);}\n.by{background:rgba(210,153,34,.15);color:#d29922;border:1px solid rgba(210,153,34,.3);}\nh1{display:none;}\nh2{font-size:19px;color:var(--ac);margin:36px 0 14px;padding-bottom:7px;border-bottom:1px solid var(--bdr);scroll-margin-top:20px;}\nh3{font-size:15px;color:var(--ac2);margin:22px 0 10px;scroll-margin-top:20px;}\np{margin:0 0 10px;}\nul,ol{padding-left:20px;margin:7px 0 10px;}\nli{margin:3px 0;}\na{color:var(--ac);text-decoration:none;}\na:hover{text-decoration:underline;}\nhr{border:none;border-top:1px solid var(--bdr);margin:28px 0;}\nblockquote{border-left:3px solid var(--ac3);padding:7px 14px;background:rgba(210,153,34,.07);border-radius:0 6px 6px 0;color:var(--mt);margin:10px 0;font-size:14px;}\ntable{width:100%;border-collapse:collapse;margin:14px 0;font-size:14px;}\nth{background:var(--card);color:var(--ac);padding:8px 11px;border:1px solid var(--bdr);text-align:left;}\ntd{padding:8px 11px;border:1px solid var(--bdr);vertical-align:top;}\ntr:nth-child(even) td{background:rgba(255,255,255,.015);}\ncode{font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:13px;background:var(--card);border:1px solid var(--bdr);padding:2px 5px;border-radius:4px;color:#e3b341;}\npre{background:var(--card);border:1px solid var(--bdr);border-radius:8px;padding:14px 18px;overflow-x:auto;margin:10px 0 14px;}\npre code{background:none;border:none;padding:0;color:#e6edf3;}\n#srch{width:100%;padding:7px 11px;background:var(--bg);border:1px solid var(--bdr);border-radius:6px;color:var(--tx);font-size:13px;margin-bottom:10px;outline:none;}\n#srch:focus{border-color:var(--ac);}\n#top{position:fixed;bottom:22px;right:22px;background:var(--ac);color:#000;width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:17px;font-weight:bold;box-shadow:0 3px 10px rgba(0,0,0,.4);text-decoration:none;opacity:.8;}\n#top:hover{opacity:1;transform:scale(1.1);}\n#prog{position:fixed;top:0;left:270px;right:0;height:3px;background:linear-gradient(90deg,var(--ac),var(--ac2));transform-origin:left;transform:scaleX(0);transition:transform .1s;z-index:200;}\n@media(max-width:768px){#sb{display:none;}#mn{margin-left:0;padding:18px;}#prog{left:0;}}\n\"\"\"\n\n_JS = \"\"\"\nwindow.addEventListener('scroll',()=>{\n const d=document.documentElement;\n document.getElementById('prog').style.transform=`scaleX(${d.scrollTop/(d.scrollHeight-d.clientHeight)||0})`;\n});\nconst hs=document.querySelectorAll('h2,h3');\nconst ls=document.querySelectorAll('#sb a');\nhs.forEach(h=>new IntersectionObserver(es=>{es.forEach(e=>{\n if(e.isIntersecting){ls.forEach(l=>l.classList.remove('on'));\n const a=document.querySelector(`#sb a[href=\"#${e.target.id}\"]`);\n if(a){a.classList.add('on');a.scrollIntoView({block:'nearest'});}\n }\n});},{rootMargin:'-20px 0px -80% 0px'}).observe(h));\nfunction fs(q){q=q.toLowerCase();document.querySelectorAll('#sb li').forEach(li=>{\n li.style.display=(!q||li.textContent.toLowerCase().includes(q))?'':'none';\n});}\n\"\"\"\n\n\ndef build_html(md_text: str, title: str, nc_cnt: int, xhs_cnt: int) -> str:\n html_body = md_lib.markdown(\n md_text,\n extensions=[\"tables\", \"fenced_code\", \"toc\", \"nl2br\"],\n extension_configs={\"toc\": {\"toc_depth\": \"2-3\"}}\n )\n toc_items = []\n for m in re.finditer(r'<h([23])[^>]*id=\"([^\"]*)\"[^>]*>(.*?)</h\\1>', html_body, re.DOTALL):\n lv, anc, txt = m.group(1), m.group(2), m.group(3)\n clean = re.sub(r\"<[^>]+>\", \"\", txt).strip()\n cls = \"h2a\" if lv == \"2\" else \"h3a\"\n toc_items.append(f'<li><a href=\"#{anc}\" class=\"{cls}\">{clean}</a></li>')\n toc = \"<ul>\" + \"\".join(toc_items) + \"</ul>\"\n\n return f\"\"\"<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">\n<title>{title}</title>\n<style>{_CSS}</style>\n</head>\n<body>\n<div id=\"prog\"></div>\n<nav id=\"sb\">\n <div id=\"sbh\">\n <h2>📚 目录导航</h2>\n <input id=\"srch\" type=\"text\" placeholder=\"搜索章节...\" oninput=\"fs(this.value)\">\n </div>\n {toc}\n</nav>\n<main id=\"mn\">\n <div class=\"hero\">\n <div class=\"hero-t\">{title}</div>\n <p style=\"margin-top:7px\">\n <span class=\"badge bb\">牛客网:{nc_cnt} 篇</span>\n <span class=\"badge bg\">小红书:{xhs_cnt} 篇</span>\n <span class=\"badge by\">合计:{nc_cnt + xhs_cnt} 篇</span>\n </p>\n </div>\n {html_body}\n</main>\n<a href=\"#\" id=\"top\" title=\"返回顶部\">↑</a>\n<script>{_JS}</script>\n</body>\n</html>\"\"\"\n\n\ndef run(nc_file: str, xhs_file: str, output_dir: str, title: str,\n filter_mode: str = \"all\", max_len: int = 2000,\n formats: list = None, verbose: bool = True) -> dict:\n \"\"\"生成报告\"\"\"\n formats = formats or [\"md\", \"html\"]\n os.makedirs(output_dir, exist_ok=True)\n\n nc_posts = json.load(open(nc_file, encoding=\"utf-8\")) if os.path.exists(nc_file) else []\n xhs_posts = json.load(open(xhs_file, encoding=\"utf-8\")) if os.path.exists(xhs_file) else []\n\n nc_posts = filter_posts(nc_posts, filter_mode)\n xhs_posts = filter_posts(xhs_posts, filter_mode)\n\n if verbose:\n print(f\"📊 过滤模式: {filter_mode}\")\n print(f\" 牛客: {len(nc_posts)} 篇 | 小红书: {len(xhs_posts)} 篇\")\n\n md_text = build_md(nc_posts, xhs_posts, title, max_len)\n results = {}\n\n safe_title = re.sub(r'[\\\\/:*?\"<>|]', '_', title)\n\n if \"md\" in formats:\n md_path = os.path.join(output_dir, f\"{safe_title}.md\")\n with open(md_path, \"w\", encoding=\"utf-8\") as f:\n f.write(md_text)\n results[\"md\"] = md_path\n if verbose:\n print(f\"✅ MD → {md_path} ({len(md_text)//1024}KB)\")\n\n if \"html\" in formats:\n html_text = build_html(md_text, title, len(nc_posts), len(xhs_posts))\n html_path = os.path.join(output_dir, f\"{safe_title}.html\")\n with open(html_path, \"w\", encoding=\"utf-8\") as f:\n f.write(html_text)\n results[\"html\"] = html_path\n if verbose:\n print(f\"✅ HTML → {html_path} ({len(html_text)//1024}KB)\")\n\n return results\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"MD/HTML 报告生成器\")\n parser.add_argument(\"--config\", default=\"config.json\")\n parser.add_argument(\"--nc\", default=\"output/nowcoder.json\", help=\"牛客数据 JSON\")\n parser.add_argument(\"--xhs\", default=\"output/xiaohongshu.json\", help=\"小红书数据 JSON\")\n parser.add_argument(\"--title\", help=\"报告标题\")\n parser.add_argument(\"--filter\", choices=[\"all\", \"backend\"], default=\"all\")\n parser.add_argument(\"--output\", help=\"输出目录\")\n args = parser.parse_args()\n\n cfg = {}\n if os.path.exists(args.config):\n with open(args.config, encoding=\"utf-8\") as f:\n cfg = json.load(f)\n\n title = args.title or cfg.get(\"output\", {}).get(\"title\", \"面经备考手册\")\n output_dir = args.output or cfg.get(\"output\", {}).get(\"dir\", \"output\")\n formats = cfg.get(\"output\", {}).get(\"formats\", [\"md\", \"html\"])\n filter_m = cfg.get(\"filter\", {}).get(\"mode\", args.filter)\n max_len = cfg.get(\"output\", {}).get(\"max_content_length\", 2000)\n\n run(nc_file=args.nc, xhs_file=args.xhs, output_dir=output_dir,\n title=title, filter_mode=filter_m, max_len=max_len, formats=formats)\n\n\nif __name__ == \"__main__\":\n main()\n"}}, "mcp_server.py": {"5e81eb8db7ffc59bc431f3912a31dcbad679197d": {"t": 1785603942.6252022, "n": 7477, "h": "a89de158df916eec", "c": "#!/usr/bin/env python3\n\"\"\"\n面经猎手 MCP Server\n让 Cursor / Claude 可以直接调用本工具\n\n配置方法(~/.cursor/mcp.json):\n{\n \"mcpServers\": {\n \"interview-spider\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/interview-experience-spider/mcp_server.py\"]\n }\n }\n}\n\n然后在 Cursor 中说:\"帮我搜索字节飞书后端面经\"\n\"\"\"\nimport os, sys, json\n\nos.chdir(os.path.dirname(os.path.abspath(__file__)))\n\ntry:\n from mcp.server.fastmcp import FastMCP\nexcept ImportError:\n print(\"❌ 缺少 mcp 库,请运行: pip3 install mcp\", file=sys.stderr)\n sys.exit(1)\n\nmcp = FastMCP(\"面经猎手 Interview Spider\")\n\nCONFIG_FILE = os.path.join(os.path.dirname(__file__), \"config.json\")\nOUTPUT_DIR = \"output\"\n\n\ndef _load_config() -> dict:\n if os.path.exists(CONFIG_FILE):\n with open(CONFIG_FILE, encoding=\"utf-8\") as f:\n return json.load(f)\n return {}\n\n\n# ===== Tool 1: 搜索牛客网 =====\n@mcp.tool()\ndef search_nowcoder(\n query: str,\n pages: int = 3,\n output_file: str = \"output/nowcoder.json\"\n) -> str:\n \"\"\"\n 搜索牛客网面经,并保存到 JSON 文件。\n \n 参数:\n query - 搜索关键词,如\"字节飞书后端实习\"\n pages - 搜索页数(默认3页,每页约20条)\n output_file - 结果保存路径\n \n 返回:\n 抓取结果摘要\n \"\"\"\n try:\n from scrape_nowcoder import run\n posts = run(\n queries=[query],\n max_pages=pages,\n delay=0.8,\n output_file=output_file,\n verbose=False\n )\n titles = [f\"• {p['title'][:40]}\" for p in posts[:5]]\n return (\n f\"✅ 牛客网搜索完成\\n\"\n f\"关键词:{query}\\n\"\n f\"共找到 {len(posts)} 篇面经\\n\"\n f\"保存到:{os.path.abspath(output_file)}\\n\\n\"\n f\"前5篇标题:\\n\" + \"\\n\".join(titles)\n )\n except Exception as e:\n return f\"❌ 搜索失败: {e}\"\n\n\n# ===== Tool 2: 搜索小红书 =====\n@mcp.tool()\ndef search_xiaohongshu(\n query: str,\n pages: int = 3,\n output_file: str = \"output/xiaohongshu.json\"\n) -> str:\n \"\"\"\n 搜索小红书面经(需要在 config.json 中配置 Cookie)。\n \n 参数:\n query - 搜索关键词,如\"字节飞书面试\"\n pages - 搜索页数\n output_file - 结果保存路径\n \n Cookie 配置见 docs/get-xhs-cookies.md\n \"\"\"\n cfg = _load_config()\n cookies = cfg.get(\"xiaohongshu\", {}).get(\"cookies\", {})\n if not cookies.get(\"a1\") or \"填入\" in cookies.get(\"a1\", \"\"):\n return (\n \"❌ 小红书 Cookie 未配置\\n\"\n \"请编辑 config.json,填入 a1/web_session/webId/gid\\n\"\n \"获取方法见 docs/get-xhs-cookies.md\"\n )\n try:\n from scrape_xhs import run\n posts = run(\n queries=[query],\n cookies=cookies,\n max_pages=pages,\n delay=0.8,\n output_file=output_file,\n verbose=False\n )\n titles = [f\"• {p['title'][:40]}\" for p in posts[:5]]\n return (\n f\"✅ 小红书搜索完成\\n\"\n f\"关键词:{query}\\n\"\n f\"共找到 {len(posts)} 篇\\n\"\n f\"保存到:{os.path.abspath(output_file)}\\n\\n\"\n f\"前5篇标题:\\n\" + \"\\n\".join(titles)\n )\n except Exception as e:\n return f\"❌ 搜索失败: {e}\"\n\n\n# ===== Tool 3: 生成报告 =====\n@mcp.tool()\ndef generate_report(\n title: str = \"面经备考手册\",\n nc_file: str = \"output/nowcoder.json\",\n xhs_file: str = \"output/xiaohongshu.json\",\n filter_mode: str = \"all\"\n) -> str:\n \"\"\"\n 根据已抓取的面经数据,生成 MD + HTML 报告。\n \n 参数:\n title - 报告标题\n nc_file - 牛客数据 JSON 路径\n xhs_file - 小红书数据 JSON 路径\n filter_mode - \"all\"(全部)或 \"backend\"(只保留后端/Go相关)\n \n 返回:\n 生成的文件路径\n \"\"\"\n try:\n from generate_report import run\n results = run(\n nc_file=nc_file,\n xhs_file=xhs_file,\n output_dir=OUTPUT_DIR,\n title=title,\n filter_mode=filter_mode,\n formats=[\"md\", \"html\"],\n verbose=False\n )\n lines = [\"✅ 报告生成完成\"]\n for fmt, path in results.items():\n lines.append(f\" {fmt.upper()} → {os.path.abspath(path)}\")\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"❌ 生成失败: {e}\"\n\n\n# ===== Tool 4: 一键全流程 =====\n@mcp.tool()\ndef collect_all(\n company: str = \"字节飞书\",\n position: str = \"后端实习\",\n filter_mode: str = \"all\"\n) -> str:\n \"\"\"\n 一键全流程:自动构建搜索关键词 → 爬取牛客+小红书 → 生成 HTML 报告。\n \n 参数:\n company - 公司名,如\"字节飞书\"、\"腾讯微信\"\n position - 岗位名,如\"后端实习\"、\"golang开发\"\n filter_mode - \"all\" 或 \"backend\"\n \n 用法示例:\n collect_all(\"字节飞书\", \"服务端实习\")\n collect_all(\"腾讯\", \"C++客户端\")\n \"\"\"\n cfg = _load_config()\n cookies = cfg.get(\"xiaohongshu\", {}).get(\"cookies\", {})\n has_xhs = bool(cookies.get(\"a1\") and \"填入\" not in cookies.get(\"a1\", \"\"))\n\n queries = [\n f\"{company}{position}\",\n f\"{company} {position} 面经\",\n f\"{company} {position} golang\",\n f\"{company} {position} 一面 二面\",\n ]\n\n results_summary = [f\"🚀 开始收集:{company} {position}\\n\"]\n\n # 爬取牛客\n try:\n from scrape_nowcoder import run as nc_run\n nc_posts = nc_run(\n queries=queries,\n max_pages=cfg.get(\"nowcoder\", {}).get(\"max_pages\", 3),\n delay=0.8,\n output_file=f\"output/nowcoder.json\",\n verbose=False\n )\n results_summary.append(f\"✅ 牛客网:{len(nc_posts)} 篇\")\n except Exception as e:\n results_summary.append(f\"❌ 牛客网失败: {e}\")\n nc_posts = []\n\n # 爬取小红书\n if has_xhs:\n try:\n from scrape_xhs import run as xhs_run\n xhs_posts = xhs_run(\n queries=queries,\n cookies=cookies,\n max_pages=cfg.get(\"xiaohongshu\", {}).get(\"max_pages\", 3),\n delay=0.8,\n output_file=f\"output/xiaohongshu.json\",\n verbose=False\n )\n results_summary.append(f\"✅ 小红书:{len(xhs_posts)} 篇\")\n except Exception as e:\n results_summary.append(f\"❌ 小红书失败: {e}\")\n else:\n results_summary.append(\"⚠️ 小红书未配置 Cookie,已跳过\")\n\n # 生成报告\n try:\n from generate_report import run as report_run\n title = f\"{company} {position} 面经备考手册\"\n results = report_run(\n nc_file=\"output/nowcoder.json\",\n xhs_file=\"output/xiaohongshu.json\",\n output_dir=OUTPUT_DIR,\n title=title,\n filter_mode=filter_mode,\n formats=[\"md\", \"html\"],\n verbose=False\n )\n for fmt, path in results.items():\n results_summary.append(f\"📄 {fmt.upper()} → {os.path.abspath(path)}\")\n except Exception as e:\n results_summary.append(f\"❌ 报告生成失败: {e}\")\n\n return \"\\n\".join(results_summary)\n\n\n# ===== Tool 5: 查看已有数据 =====\n@mcp.tool()\ndef list_collected(\n source: str = \"all\"\n) -> str:\n \"\"\"\n 查看已收集的面经数据摘要。\n \n 参数:\n source - \"nowcoder\"、\"xiaohongshu\" 或 \"all\"\n \"\"\"\n lines = []\n files = {\n \"nowcoder\": \"output/nowcoder.json\",\n \"xiaohongshu\": \"output/xiaohongshu.json\",\n }\n for src, path in files.items():\n if source not in (\"all\", src):\n continue\n if os.path.exists(path):\n data = json.load(open(path, encoding=\"utf-8\"))\n lines.append(f\"📁 {src}({len(data)} 篇)→ {os.path.abspath(path)}\")\n for i, d in enumerate(data[:5]):\n lines.append(f\" {i+1}. {d.get('title','无标题')[:45]}\")\n if len(data) > 5:\n lines.append(f\" ... 共 {len(data)} 篇\")\n else:\n lines.append(f\"📁 {src} → 暂无数据({path} 不存在)\")\n lines.append(\"\")\n return \"\\n\".join(lines) if lines else \"暂无已收集的数据\"\n\n\nif __name__ == \"__main__\":\n mcp.run()\n"}}, "run_all.py": {"a0aa20012e154875ee71fb1e9a0ccf919381fc49": {"t": 1785603944.1451764, "n": 5147, "h": "90819316a0498905", "c": "#!/usr/bin/env python3\n\"\"\"\n🚀 一键运行:搜索 + 抓取 + 生成报告\n用法:\n python3 run_all.py # 使用 config.json\n python3 run_all.py --filter backend # 只保留后端/Go 相关\n python3 run_all.py --skip-nc # 跳过牛客,只爬小红书\n python3 run_all.py --skip-xhs # 跳过小红书,只爬牛客\n\"\"\"\nimport os, sys, json, argparse, subprocess\n\ndef check_env():\n \"\"\"检查运行环境\"\"\"\n print(\"🔍 检查运行环境...\")\n\n # Python 依赖\n missing = []\n for pkg in [\"requests\", \"execjs\", \"markdown\"]:\n try:\n __import__(pkg.replace(\"-\", \"_\"))\n except ImportError:\n missing.append(pkg)\n if missing:\n print(f\"❌ 缺少 Python 依赖: {', '.join(missing)}\")\n print(f\" 请运行: pip3 install {' '.join(missing)}\")\n sys.exit(1)\n\n # Node.js 依赖(小红书签名需要)\n node_modules = os.path.join(os.path.dirname(__file__), \"Spider_XHS_signing\", \"node_modules\")\n if not os.path.exists(node_modules):\n print(\"⚠️ 小红书签名模块未初始化\")\n print(\" 正在自动初始化...\")\n signing_dir = os.path.join(os.path.dirname(__file__), \"Spider_XHS_signing\")\n result = subprocess.run([\"npm\", \"install\"], cwd=signing_dir, capture_output=True, text=True)\n if result.returncode != 0:\n print(f\" ❌ npm install 失败: {result.stderr}\")\n print(f\" 请手动运行: cd Spider_XHS_signing && npm install\")\n else:\n print(\" ✅ Node.js 依赖安装完成\")\n\n # config.json\n if not os.path.exists(\"config.json\"):\n print(\"⚠️ 未找到 config.json\")\n if os.path.exists(\"config.example.json\"):\n print(\" 提示:请先执行 cp config.example.json config.json 并填写配置\")\n sys.exit(1)\n\n print(\"✅ 环境检查通过\\n\")\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"面经猎手 - 一键运行\")\n parser.add_argument(\"--config\", default=\"config.json\")\n parser.add_argument(\"--filter\", choices=[\"all\", \"backend\"], help=\"过滤模式(覆盖 config.json)\")\n parser.add_argument(\"--skip-nc\", action=\"store_true\", help=\"跳过牛客网爬取\")\n parser.add_argument(\"--skip-xhs\", action=\"store_true\", help=\"跳过小红书爬取\")\n parser.add_argument(\"--report-only\", action=\"store_true\", help=\"只生成报告(用已有数据)\")\n args = parser.parse_args()\n\n os.chdir(os.path.dirname(os.path.abspath(__file__)))\n check_env()\n\n with open(args.config, encoding=\"utf-8\") as f:\n cfg = json.load(f)\n\n output_dir = cfg.get(\"output\", {}).get(\"dir\", \"output\")\n os.makedirs(output_dir, exist_ok=True)\n\n nc_file = os.path.join(output_dir, \"nowcoder.json\")\n xhs_file = os.path.join(output_dir, \"xiaohongshu.json\")\n\n # ===== 步骤1:爬取牛客 =====\n if not args.report_only and not args.skip_nc:\n print(\"=\" * 50)\n print(\"📥 步骤1:爬取牛客网面经\")\n print(\"=\" * 50)\n from scrape_nowcoder import run as nc_run\n queries = cfg.get(\"nowcoder\", {}).get(\"queries\", [\"字节飞书面经\"])\n max_pages = cfg.get(\"nowcoder\", {}).get(\"max_pages\", 3)\n delay = cfg.get(\"nowcoder\", {}).get(\"delay_seconds\", 0.8)\n nc_run(queries=queries, max_pages=max_pages, delay=delay, output_file=nc_file)\n print()\n else:\n print(\"⏭️ 跳过牛客网爬取\\n\")\n\n # ===== 步骤2:爬取小红书 =====\n if not args.report_only and not args.skip_xhs:\n xhs_cfg = cfg.get(\"xiaohongshu\", {})\n if xhs_cfg.get(\"enabled\", True):\n cookies = xhs_cfg.get(\"cookies\", {})\n if not cookies.get(\"a1\") or \"填入\" in cookies.get(\"a1\", \"\"):\n print(\"⚠️ 小红书 Cookie 未配置,跳过小红书爬取\")\n print(\" 配置方法见 docs/get-xhs-cookies.md\\n\")\n else:\n print(\"=\" * 50)\n print(\"📥 步骤2:爬取小红书面经\")\n print(\"=\" * 50)\n from scrape_xhs import run as xhs_run\n queries = xhs_cfg.get(\"queries\", [\"字节飞书面试\"])\n max_pages = xhs_cfg.get(\"max_pages\", 3)\n delay = xhs_cfg.get(\"delay_seconds\", 0.8)\n xhs_run(queries=queries, cookies=cookies, max_pages=max_pages,\n delay=delay, output_file=xhs_file)\n print()\n else:\n print(\"⏭️ config.json 中 xiaohongshu.enabled=false,跳过\\n\")\n else:\n print(\"⏭️ 跳过小红书爬取\\n\")\n\n # ===== 步骤3:生成报告 =====\n print(\"=\" * 50)\n print(\"📝 步骤3:生成 MD/HTML 报告\")\n print(\"=\" * 50)\n from generate_report import run as report_run\n\n title = cfg.get(\"output\", {}).get(\"title\", \"面经备考手册\")\n formats = cfg.get(\"output\", {}).get(\"formats\", [\"md\", \"html\"])\n max_len = cfg.get(\"output\", {}).get(\"max_content_length\", 2000)\n filter_m = args.filter or cfg.get(\"filter\", {}).get(\"mode\", \"all\")\n\n results = report_run(\n nc_file=nc_file, xhs_file=xhs_file,\n output_dir=output_dir, title=title,\n filter_mode=filter_m, max_len=max_len, formats=formats\n )\n\n print()\n print(\"=\" * 50)\n print(\"🎉 全部完成!\")\n print(\"=\" * 50)\n for fmt, path in results.items():\n print(f\" {fmt.upper()} → {os.path.abspath(path)}\")\n\n # 自动用浏览器打开 HTML\n if \"html\" in results:\n import subprocess\n try:\n subprocess.Popen([\"open\", results[\"html\"]])\n print(\"\\n 已自动在浏览器打开 HTML 文件 🚀\")\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n main()\n"}}, "scrape_nowcoder.py": {"8fe322649d07bcfcabe18babb412c7ee8f2b882b": {"t": 1785603945.4394035, "n": 8488, "h": "ad7d3dcc4393ba4a", "c": "#!/usr/bin/env python3\n\"\"\"\n牛客网面经爬虫\n用法:\n python3 scrape_nowcoder.py # 使用 config.json\n python3 scrape_nowcoder.py --query \"字节飞书\" --pages 5\n python3 scrape_nowcoder.py --output output/nc.json\n\"\"\"\nimport re, time, json, argparse, sys, os\n\ntry:\n import requests\nexcept ImportError:\n print(\"❌ 缺少依赖,请先运行: pip3 install requests\")\n sys.exit(1)\n\nSEARCH_API = \"https://gw-c.nowcoder.com/api/sparta/pc/search\"\nDISCUSS_API = \"https://gw-c.nowcoder.com/api/sparta/detail/content-data/detail\"\nFEED_URL = \"https://www.nowcoder.com/feed/main/detail\"\n\nHEADERS = {\n \"Content-Type\": \"application/json; charset=UTF-8\",\n \"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\",\n}\n\n\ndef html_to_text(html: str) -> str:\n if not html:\n return \"\"\n html = re.sub(r\"<script[^>]*>.*?</script>\", \"\", html, flags=re.DOTALL | re.IGNORECASE)\n html = re.sub(r\"<style[^>]*>.*?</style>\", \"\", html, flags=re.DOTALL | re.IGNORECASE)\n html = re.sub(r\"</?(p|div|br|h[1-6]|li|tr)[^>]*>\", \"\\n\", html, flags=re.IGNORECASE)\n html = re.sub(r\"<[^>]+>\", \"\", html)\n for old, new in [(\" \", \" \"), (\"&\", \"&\"), (\"<\", \"<\"),\n (\">\", \">\"), (\""\", '\"'), (\"'\", \"'\"), (\"\\xa0\", \" \")]:\n html = html.replace(old, new)\n html = re.sub(r\"\\n\\s*\\n\", \"\\n\\n\", html)\n return html.strip()\n\n\ndef search(query: str, max_pages: int = 3, delay: float = 0.8) -> list:\n \"\"\"搜索牛客网面经,返回帖子元信息列表\"\"\"\n results = []\n seen = set()\n for page in range(1, max_pages + 1):\n try:\n payload = {\n \"type\": \"all\",\n \"query\": query,\n \"page\": page,\n \"tag\": [{\"name\": \"面经\", \"id\": 818, \"count\": None}],\n \"order\": \"create\",\n \"gioParams\": {\"searchFrom_var\": \"顶部导航栏\", \"searchEnter_var\": \"主站\"},\n }\n resp = requests.post(SEARCH_API, json=payload, headers=HEADERS, timeout=15)\n data = resp.json()\n if not data.get(\"success\"):\n break\n records = data.get(\"data\", {}).get(\"records\", [])\n if not records:\n break\n for r in records:\n rc_type = r.get(\"rc_type\", 0)\n rd = r.get(\"data\", {})\n if rc_type == 201:\n md = rd.get(\"momentData\", {})\n if md:\n uid = md.get(\"uuid\", \"\")\n title = md.get(\"title\", \"\")\n if uid and uid not in seen:\n seen.add(uid)\n results.append({\"rc_type\": 201, \"uuid\": uid, \"title\": title, \"query\": query})\n elif rc_type == 207:\n cd = rd.get(\"contentData\", {})\n if cd:\n cid = str(cd.get(\"id\", \"\"))\n title = cd.get(\"title\", \"\")\n if cid and cid not in seen:\n seen.add(cid)\n results.append({\"rc_type\": 207, \"content_id\": cid, \"title\": title, \"query\": query})\n total_page = data.get(\"data\", {}).get(\"totalPage\", 1)\n if page >= total_page:\n break\n time.sleep(delay)\n except Exception as e:\n print(f\" ⚠️ 搜索第{page}页出错: {e}\")\n break\n return results\n\n\ndef fetch_feed(uuid: str) -> dict | None:\n \"\"\"抓取 Feed 类型帖子正文\"\"\"\n try:\n resp = requests.get(\n f\"{FEED_URL}/{uuid}\",\n headers={\"User-Agent\": HEADERS[\"User-Agent\"]},\n timeout=15\n )\n html = resp.text\n if \"内容不存在\" in html:\n return None\n title_m = re.search(r'\"title\":\"([^\"]+)\"', html)\n title = title_m.group(1) if title_m else \"\"\n content = \"\"\n cm = re.search(\n r'<div[^>]*class=\"[^\"]*feed-content-text[^\"]*\"[^>]*>(.*?)</div>',\n html, re.DOTALL | re.IGNORECASE\n )\n if cm:\n content = html_to_text(cm.group(1))\n if not content:\n for m in re.findall(r'\"content\":\"([^\"]{100,})\"', html):\n content = m.replace(\"\\\\n\", \"\\n\").replace(\"\\\\u002F\", \"/\").replace(\"\\\\t\", \"\\t\")\n break\n return {\"title\": title, \"content\": content, \"url\": f\"{FEED_URL}/{uuid}\"}\n except Exception as e:\n print(f\" ⚠️ Feed 抓取失败 {uuid}: {e}\")\n return None\n\n\ndef fetch_discuss(content_id: str) -> dict | None:\n \"\"\"抓取 Discuss 类型帖子正文\"\"\"\n try:\n resp = requests.get(\n f\"{DISCUSS_API}/{content_id}\",\n headers={\"User-Agent\": HEADERS[\"User-Agent\"]},\n timeout=15\n )\n data = resp.json()\n if not data.get(\"success\"):\n return None\n cd = data.get(\"data\", {})\n rich = cd.get(\"richText\", \"\") or cd.get(\"content\", \"\")\n return {\n \"title\": cd.get(\"title\", \"\"),\n \"content\": html_to_text(rich),\n \"url\": f\"https://www.nowcoder.com/discuss/{content_id}\",\n }\n except Exception as e:\n print(f\" ⚠️ Discuss 抓取失败 {content_id}: {e}\")\n return None\n\n\ndef run(queries: list, max_pages: int = 3, delay: float = 0.8,\n output_file: str = \"output/nowcoder.json\", verbose: bool = True) -> list:\n \"\"\"主函数:搜索 + 抓取正文,返回结果列表\"\"\"\n os.makedirs(os.path.dirname(output_file) or \".\", exist_ok=True)\n\n # 第一步:搜索,去重\n all_posts = {}\n for q in queries:\n if verbose:\n print(f\"🔍 搜索: {q}\")\n results = search(q, max_pages=max_pages, delay=delay)\n new = 0\n for r in results:\n key = r.get(\"uuid\") or r.get(\"content_id\")\n if key and key not in all_posts:\n all_posts[key] = r\n new += 1\n if verbose:\n print(f\" 新增 {new} 条(累计 {len(all_posts)} 条)\")\n time.sleep(1)\n\n if verbose:\n print(f\"\\n📄 共 {len(all_posts)} 条,开始抓取正文...\\n\")\n\n # 第二步:抓取正文\n collected = []\n for i, (key, info) in enumerate(all_posts.items()):\n title_short = (info.get(\"title\") or key)[:50]\n if verbose:\n print(f\"[{i+1}/{len(all_posts)}] {title_short}\")\n\n if info[\"rc_type\"] == 201:\n detail = fetch_feed(info[\"uuid\"])\n else:\n detail = fetch_discuss(info[\"content_id\"])\n\n if detail and len(detail.get(\"content\", \"\")) > 80:\n collected.append({\n \"title\": detail[\"title\"] or info[\"title\"],\n \"content\": detail[\"content\"],\n \"url\": detail[\"url\"],\n \"query\": info[\"query\"],\n \"source\": \"nowcoder\",\n })\n if verbose:\n print(f\" ✅ {len(detail['content'])} 字\")\n else:\n if verbose:\n print(f\" ❌ 无正文\")\n time.sleep(delay)\n\n with open(output_file, \"w\", encoding=\"utf-8\") as f:\n json.dump(collected, f, ensure_ascii=False, indent=2)\n\n if verbose:\n print(f\"\\n✅ 牛客面经抓取完成:{len(collected)}/{len(all_posts)} 篇有效\")\n print(f\"📁 保存到: {output_file}\")\n\n return collected\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"牛客网面经爬虫\")\n parser.add_argument(\"--config\", default=\"config.json\", help=\"配置文件路径\")\n parser.add_argument(\"--query\", help=\"单个搜索关键词(覆盖 config.json)\")\n parser.add_argument(\"--pages\", type=int, help=\"每个关键词搜索页数\")\n parser.add_argument(\"--output\", default=\"output/nowcoder.json\", help=\"输出 JSON 文件路径\")\n args = parser.parse_args()\n\n queries = [args.query] if args.query else None\n max_pages = args.pages\n\n if not queries:\n if os.path.exists(args.config):\n with open(args.config, encoding=\"utf-8\") as f:\n cfg = json.load(f)\n queries = cfg.get(\"nowcoder\", {}).get(\"queries\", [\"字节飞书面经\"])\n max_pages = max_pages or cfg.get(\"nowcoder\", {}).get(\"max_pages\", 3)\n delay = cfg.get(\"nowcoder\", {}).get(\"delay_seconds\", 0.8)\n output = cfg.get(\"output\", {}).get(\"dir\", \"output\") + \"/nowcoder.json\"\n else:\n print(f\"⚠️ 未找到 {args.config},使用默认关键词\")\n queries = [\"字节飞书后端实习\"]\n max_pages = 3\n delay = 0.8\n output = args.output\n else:\n max_pages = max_pages or 3\n delay = 0.8\n output = args.output\n\n run(queries=queries, max_pages=max_pages, delay=delay, output_file=output)\n\n\nif __name__ == \"__main__\":\n main()\n"}}, "scrape_xhs.py": {"2ec5d1a1f0aea92e838ff142fd001e0c2a6be515": {"t": 1785603947.9690757, "n": 8907, "h": "68289d32781c29b3", "c": "#!/usr/bin/env python3\n\"\"\"\n小红书面经爬虫(需要 Cookie + Spider_XHS 签名)\n用法:\n python3 scrape_xhs.py # 使用 config.json\n python3 scrape_xhs.py --query \"字节飞书\" --config config.json\n python3 scrape_xhs.py --output output/xhs.json\n如何获取 Cookie?见 docs/get-xhs-cookies.md\n\"\"\"\nimport re, time, json, argparse, sys, os\n\ntry:\n import requests\nexcept ImportError:\n print(\"❌ 缺少依赖,请先运行: pip3 install requests\")\n sys.exit(1)\n\n# 设置 Spider_XHS_signing 路径\nSCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))\nSIGNING_DIR = os.path.join(SCRIPT_DIR, \"Spider_XHS_signing\")\nNODE_MODULES = os.path.join(SIGNING_DIR, \"node_modules\")\n\nif not os.path.exists(NODE_MODULES):\n print(\"❌ 未找到 Spider_XHS_signing/node_modules\")\n print(\" 请先运行: cd Spider_XHS_signing && npm install\")\n sys.exit(1)\n\nsys.path.insert(0, SIGNING_DIR)\nos.environ[\"NODE_PATH\"] = NODE_MODULES\n\ntry:\n from xhs_utils.xhs_util import generate_xs_xs_common, generate_search_id\nexcept ImportError as e:\n print(f\"❌ 导入签名工具失败: {e}\")\n print(\" 请确保已安装 PyExecJS: pip3 install PyExecJS\")\n sys.exit(1)\n\n\ndef build_headers(a1: str, api_path: str, data_str: str = \"\", method: str = \"POST\",\n extra_cookies: dict = None) -> dict:\n xs, xt, xs_common = generate_xs_xs_common(a1, api_path, data_str, method)\n cookies = {\n \"a1\": a1,\n \"web_session\": extra_cookies.get(\"web_session\", \"\"),\n \"webId\": extra_cookies.get(\"webId\", \"\"),\n \"gid\": extra_cookies.get(\"gid\", \"\"),\n }\n cookie_str = \"; \".join(f\"{k}={v}\" for k, v in cookies.items() if v)\n return {\n \"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36\",\n \"Referer\": \"https://www.xiaohongshu.com/\",\n \"Origin\": \"https://www.xiaohongshu.com\",\n \"Accept\": \"application/json, text/plain, */*\",\n \"Accept-Language\": \"zh-CN,zh;q=0.9\",\n \"Content-Type\": \"application/json;charset=UTF-8\",\n \"Cookie\": cookie_str,\n \"x-s\": xs,\n \"x-t\": str(xt),\n \"x-s-common\": xs_common,\n }\n\n\ndef search_notes(keyword: str, page: int = 1, page_size: int = 20,\n cookies: dict = None) -> dict | None:\n \"\"\"搜索小红书笔记\"\"\"\n api = \"/api/sns/web/v1/search/notes\"\n payload = {\n \"keyword\": keyword,\n \"page\": page,\n \"page_size\": page_size,\n \"search_id\": generate_search_id(),\n \"sort\": \"general\",\n \"note_type\": 0,\n }\n data_str = json.dumps(payload, separators=(\",\", \":\"))\n headers = build_headers(cookies.get(\"a1\", \"\"), api, data_str, \"POST\", cookies)\n try:\n resp = requests.post(\n f\"https://edith.xiaohongshu.com{api}\",\n data=data_str,\n headers=headers,\n timeout=15\n )\n return resp.json()\n except Exception as e:\n print(f\" ⚠️ 搜索异常: {e}\")\n return None\n\n\ndef fetch_note(note_id: str, xsec_token: str = \"\", cookies: dict = None) -> dict | None:\n \"\"\"获取笔记详情\"\"\"\n api = \"/api/sns/web/v1/feed\"\n payload = {\n \"source_note_id\": note_id,\n \"image_formats\": [\"jpg\", \"webp\", \"avif\"],\n \"extra\": {\"need_body_topic\": \"1\"},\n \"xsec_source\": \"pc_search\",\n \"xsec_token\": xsec_token,\n }\n data_str = json.dumps(payload, separators=(\",\", \":\"))\n headers = build_headers(cookies.get(\"a1\", \"\"), api, data_str, \"POST\", cookies)\n try:\n resp = requests.post(\n f\"https://edith.xiaohongshu.com{api}\",\n data=data_str,\n headers=headers,\n timeout=15\n )\n result = resp.json()\n if result.get(\"success\"):\n items = result.get(\"data\", {}).get(\"items\", [])\n if items:\n note = items[0].get(\"note_card\", {})\n return {\n \"title\": note.get(\"title\", \"\"),\n \"content\": note.get(\"desc\", \"\"),\n }\n return None\n except Exception as e:\n print(f\" ⚠️ 笔记抓取异常 {note_id}: {e}\")\n return None\n\n\ndef is_relevant(title: str, content: str, keywords: list) -> bool:\n \"\"\"判断内容是否相关\"\"\"\n text = (title + content).lower()\n return any(kw.lower() in text for kw in keywords)\n\n\ndef run(queries: list, cookies: dict, max_pages: int = 3, delay: float = 0.8,\n filter_keywords: list = None, output_file: str = \"output/xiaohongshu.json\",\n verbose: bool = True) -> list:\n \"\"\"主函数:搜索 + 抓取正文,返回结果列表\"\"\"\n os.makedirs(os.path.dirname(output_file) or \".\", exist_ok=True)\n\n if not cookies.get(\"a1\"):\n print(\"❌ 未配置小红书 Cookie,请在 config.json 中填写\")\n print(\" 见 docs/get-xhs-cookies.md\")\n return []\n\n # 第一步:搜索\n all_notes = {}\n for kw in queries:\n if verbose:\n print(f\"🔍 搜索: {kw}\")\n for page in range(1, max_pages + 1):\n result = search_notes(kw, page=page, page_size=20, cookies=cookies)\n if not result:\n break\n if not result.get(\"success\"):\n code = result.get(\"code\", \"?\")\n msg = result.get(\"msg\", \"\")\n if verbose:\n print(f\" ❌ code={code} msg={msg}\")\n if code == 461:\n print(\" ⚠️ Cookie 已过期,请重新获取!见 docs/get-xhs-cookies.md\")\n break\n items = result.get(\"data\", {}).get(\"items\", [])\n new = 0\n for item in items:\n nid = item.get(\"id\", \"\")\n note_card = item.get(\"note_card\", {})\n title = note_card.get(\"display_title\") or note_card.get(\"title\", \"\")\n xsec_token = item.get(\"xsec_token\", \"\")\n if nid and nid not in all_notes:\n all_notes[nid] = {\"note_id\": nid, \"title\": title,\n \"xsec_token\": xsec_token, \"keyword\": kw}\n new += 1\n if verbose:\n print(f\" 页{page}: {len(items)} 条,新增 {new}(累计 {len(all_notes)})\")\n if len(items) < 20:\n break\n time.sleep(delay)\n time.sleep(1)\n\n if verbose:\n print(f\"\\n📄 共 {len(all_notes)} 条,开始抓取正文...\\n\")\n\n # 第二步:抓取正文\n collected = []\n for i, (nid, info) in enumerate(all_notes.items()):\n short = (info.get(\"title\") or nid)[:50]\n if verbose:\n print(f\"[{i+1}/{len(all_notes)}] {short}\")\n\n detail = fetch_note(nid, info.get(\"xsec_token\", \"\"), cookies)\n if detail and len(detail.get(\"content\", \"\")) > 30:\n title = detail[\"title\"] or info.get(\"title\", \"\")\n content = detail[\"content\"]\n # 如果设置了过滤关键词,只保留相关内容\n if filter_keywords and not is_relevant(title, content, filter_keywords):\n if verbose:\n print(f\" - 不相关,跳过\")\n time.sleep(0.3)\n continue\n collected.append({\n \"title\": title,\n \"content\": content,\n \"url\": f\"https://www.xiaohongshu.com/explore/{nid}\",\n \"source\": \"xiaohongshu\",\n \"keyword\": info.get(\"keyword\", \"\"),\n })\n if verbose:\n print(f\" ✅ {len(content)} 字\")\n else:\n if verbose:\n print(f\" ❌ 无正文\")\n time.sleep(delay)\n\n with open(output_file, \"w\", encoding=\"utf-8\") as f:\n json.dump(collected, f, ensure_ascii=False, indent=2)\n\n if verbose:\n print(f\"\\n✅ 小红书面经抓取完成:{len(collected)}/{len(all_notes)} 篇有效\")\n print(f\"📁 保存到: {output_file}\")\n\n return collected\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"小红书面经爬虫\")\n parser.add_argument(\"--config\", default=\"config.json\", help=\"配置文件路径\")\n parser.add_argument(\"--query\", help=\"单个搜索关键词\")\n parser.add_argument(\"--pages\", type=int, help=\"每个关键词搜索页数\")\n parser.add_argument(\"--output\", default=\"output/xiaohongshu.json\", help=\"输出 JSON 路径\")\n args = parser.parse_args()\n\n if not os.path.exists(args.config):\n print(f\"❌ 未找到配置文件: {args.config}\")\n print(f\" 请先执行: cp config.example.json config.json 并填入 Cookie\")\n sys.exit(1)\n\n with open(args.config, encoding=\"utf-8\") as f:\n cfg = json.load(f)\n\n cookies = cfg.get(\"xiaohongshu\", {}).get(\"cookies\", {})\n queries = [args.query] if args.query else cfg.get(\"xiaohongshu\", {}).get(\"queries\", [])\n max_pages = args.pages or cfg.get(\"xiaohongshu\", {}).get(\"max_pages\", 3)\n delay = cfg.get(\"xiaohongshu\", {}).get(\"delay_seconds\", 0.8)\n output = args.output\n filter_kws = None # 不过滤,全部保留\n\n run(queries=queries, cookies=cookies, max_pages=max_pages, delay=delay,\n filter_keywords=filter_kws, output_file=output)\n\n\nif __name__ == \"__main__\":\n main()\n"}}}} |