This commit is contained in:
2025-12-25 15:29:55 +08:00
parent c35299bf8c
commit 66e6370013
7 changed files with 397 additions and 29 deletions

4
.gitignore vendored
View File

@@ -1 +1,5 @@
node_modules node_modules
browser-context
cache

View File

@@ -1,19 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "xhs-helper",
"dependencies": {
"playwright": "^1.57.0",
},
},
},
"packages": {
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
}
}

View File

@@ -3,15 +3,26 @@
"version": "0.0.1", "version": "0.0.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"types": "typings/note.ts",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "start": "tsx src/playwright/index.ts",
"init:base": "npx playwright install",
"browser": "pm2 start start-browser.ts --name xhs-helper-browser --interpreter=tsx"
}, },
"keywords": [], "keywords": [],
"files": [
"typings",
"src",
"start-browser.ts"
],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)", "author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT", "license": "MIT",
"packageManager": "pnpm@10.26.0", "packageManager": "pnpm@10.26.0",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"playwright": "^1.57.0" "playwright": "^1.57.0"
},
"devDependencies": {
"@types/node": "^25.0.3"
} }
} }

59
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,59 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
playwright:
specifier: ^1.57.0
version: 1.57.0
devDependencies:
'@types/node':
specifier: ^25.0.3
version: 25.0.3
packages:
'@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
playwright-core@1.57.0:
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
engines: {node: '>=18'}
hasBin: true
playwright@1.57.0:
resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
engines: {node: '>=18'}
hasBin: true
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
snapshots:
'@types/node@25.0.3':
dependencies:
undici-types: 7.16.0
fsevents@2.3.2:
optional: true
playwright-core@1.57.0: {}
playwright@1.57.0:
dependencies:
playwright-core: 1.57.0
optionalDependencies:
fsevents: 2.3.2
undici-types@7.16.0: {}

View File

@@ -1,11 +1,183 @@
import { chromium } from 'playwright'; import { chromium, Page, BrowserContext } from 'playwright';
import path from 'node:path';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import fs from 'node:fs';
const execAsync = promisify(exec);
export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const main = async () => { export const main = async () => {
const browser = await chromium.launch({ headless: true }); const debugPort = 9223;
const page = await browser.newPage(); // 等待并检查端口是否监听
await page.goto('https://www.xiaohongshu.com'); for (let i = 0; i < 15; i++) {
console.log(await page.title()); await new Promise(resolve => setTimeout(resolve, 2000));
try {
// 检查端口是否被监听
const { stdout } = await execAsync(`netstat -ano | findstr :${debugPort}`);
console.log(`端口 ${debugPort} 已在监听:\n${stdout}`);
// 尝试连接
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${debugPort}`);
console.log('成功连接到 Chrome CDP!');
// 获取已有的 context
const context = browser.contexts()[0];
// 获取已有的页面或创建新页面
const page = context.pages()[0] || await context.newPage();
// console.log('Navigating to xiaohongshu.com');
// await page.goto('https://www.xiaohongshu.com/search_result?keyword=%25E5%25A4%259A%25E7%25BB%25B4%25E8%25A1%25A8%25E6%25A0%25BC&type=51');
// console.log('当前页面标题:', await page.title());
// 点击筛选按钮的示例
// await page.click('text=筛选');
// PAIU
await listenFetchRequests(context);
await sleep(2000);
// 关闭浏览器连接,不关闭实际浏览器
await hoverPickerExample(page, { keyword: '多维表格' });
// 等待更长时间让请求有机会发生
console.log('等待 API 请求...');
await sleep(5000);
// 清理所有路由,避免 TargetClosedError
await context.unrouteAll({ behavior: 'ignoreErrors' });
// 获取
await browser.close(); await browser.close();
return;
} catch (error: any) {
console.log(`尝试 ${i + 1}/15: ${(error as Error).message.slice(0, 100)}`);
}
}
throw new Error(`无法连接到 Chrome CDP端口 ${debugPort} 可能未正确启动`);
} }
main(); main();
type HoverPickerOptions = {
keyword?: string;
pushTime?: '一天内' | '一周内' | '半年内';
sort?: '综合' | '最新' | '最多点赞' | '最多评论';
searchRange?: '不限' | '已看过' | '未看过' | '已关注';
distance?: '不限' | '同城' | '附近';
scrollTimes?: number;
}
const hoverPickerExample = async (page: Page, opts?: HoverPickerOptions) => {
const { pushTime = '一天内', sort = '最新', keyword = '', scrollTimes = 5 } = opts || {};
if (keyword) {
// class为 input-box
const inputBox = await page.$('.input-box');
const input = await inputBox?.$('input');
if (input) {
//先获取当前内容,如果一样就不输入
const currentValue = await input.inputValue();
if (currentValue === keyword) {
console.log('关键词已存在,无需输入');
} else {
await input.fill(keyword);
await input.press('Enter');
console.log(`已输入关键词: ${keyword}`);
await sleep(3000); // 等待搜索结果加载
}
}
}
// 查找筛选按钮并保持 hover 状态
let filterButton = await page.$('text=筛选');
if (filterButton) {
await filterButton.hover();
console.log('鼠标悬停在筛选按钮上');
// 在保持 hover 的情况下,等待筛选面板出现并点击元素
const filterClassPanel = await page.$('.filter-panel');
if (filterClassPanel) {
console.log('筛选面板已打开');
// 点击最新选项
const latestOption = await filterClassPanel.$(`text=${sort}`);
if (latestOption) {
await latestOption.click();
console.log(`已选择${sort}选项`);
}
// 点击一周内选项
const oneWeekOption = await filterClassPanel.$(`text=${pushTime}`);
if (oneWeekOption) {
await oneWeekOption.click();
console.log(`已选择${pushTime}选项`);
}
if (opts?.distance && opts.distance !== '不限') {
const distanceOption = await filterClassPanel.$(`text=${opts.distance}`);
if (distanceOption) {
await distanceOption.click();
console.log(`已选择${opts.distance}选项`);
}
}
if (opts?.searchRange && opts.searchRange !== '不限') {
const rangeOption = await filterClassPanel.$(`text=${opts.searchRange}`);
if (rangeOption) {
await rangeOption.click();
console.log(`已选择${opts.searchRange}选项`);
}
}
// 点击收起按钮
const shouquButton = await filterClassPanel.$('text=收起');
if (shouquButton) {
await shouquButton.click();
console.log('已点击收起按钮');
}
}
}
// 将鼠标移到页面外,移除 hover 状态
await page.mouse.move(0, 0);
console.log('已移除 hover 状态');
// 自动滚动页面5次以触发更多请求
for (let i = 0; i < scrollTimes; i++) {
await page.evaluate(() => {
window.scrollBy({
top: window.innerHeight,
left: 0,
behavior: 'smooth'
});
});
console.log(`已滚动页面 ${i + 1}`);
// 判断是否滚动到底部
const isBottom = await page.evaluate(() => {
return (window.innerHeight + window.scrollY) >= document.body.scrollHeight;
});
if (isBottom) {
console.log('已到达页面底部,停止滚动');
break;
}
await sleep(2000); // 等待2秒以加载新内容
}
}
const listenFetchRequests = async (context: BrowserContext) => {
// 监听访问 https://edith.xiaohongshu.com/api/sns/web/v1/search/notes
// 返回对应的结果 - 使用 context 级别的路由,刷新后不会丢失
await context.route('https://edith.xiaohongshu.com/api/sns/web/v1/search/notes*', async (route) => {
const request = route.request();
console.log('捕获到请求:', request.url());
await route.continue();
});
// 使用 response 事件来获取响应内容
context.on('response', async (response) => {
const url = response.url();
// 打印所有 edith.xiaohongshu.com 的 API 响应
if (url.includes('edith.xiaohongshu.com/api/')) {
console.log('收到 API 响应:', url);
console.log('状态:', response.status());
if (url.includes('search/notes')) {
try {
const responseBody = await response.text();
fs.writeFileSync(path.join(process.cwd(), 'cache', Date.now().toString() + '.json'), responseBody, 'utf-8');
} catch (e) {
console.log('无法读取响应内容');
}
}
}
});
}

35
start-browser.ts Normal file
View File

@@ -0,0 +1,35 @@
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import path from 'node:path';
export const main = async () => {
// Chrome 路径和配置
const executablePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
// 使用独立的用户数据目录,避免与 Chrome 冲突
const userDataDir = path.join(process.cwd(), 'browser-context');
const debugPort = 9223;
console.log('启动 Chrome...');
console.log(`端口: ${debugPort}`);
console.log(`用户数据目录: ${userDataDir}`);
// console.log('注意:需要手动登录账号和安装插件');
// 启动 Chrome带远程调试端口
const chromeProcess = spawn(executablePath, [
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${userDataDir}`,
], {
detached: false,
stdio: 'inherit',
});
chromeProcess.on('error', (err) => {
console.error('Chrome 启动失败:', err);
});
chromeProcess.on('exit', (code, signal) => {
console.log(`Chrome 进程退出,代码: ${code}, 信号: ${signal}`);
});
}
main();

106
typings/note.ts Normal file
View File

@@ -0,0 +1,106 @@
export namespace XHS {
/** 笔记用户信息 */
export interface NoteUser {
/** 昵称 */
nick_name: string;
/** 头像URL */
avatar: string;
/** 用户ID */
user_id: string;
/** 昵称与nick_name相同 */
nickname: string;
/** 安全令牌 */
xsec_token: string;
}
/** 笔记交互信息 */
export interface InteractInfo {
/** 评论数 */
comment_count: string;
/** 分享数 */
shared_count: string;
/** 是否已点赞 */
liked: boolean;
/** 点赞数 */
liked_count: string;
/** 是否已收藏 */
collected: boolean;
/** 收藏数 */
collected_count: string;
}
/** 图片信息 */
export interface ImageInfo {
/** 图片场景如WB_DFT默认、WB_PRV预览 */
image_scene: string;
/** 图片URL */
url: string;
}
/** 笔记图片 */
export interface Image {
/** 图片宽度 */
width: number;
/** 图片高度 */
height: number;
/** 图片信息列表不同场景的URL */
info_list: ImageInfo[];
}
/** 笔记封面 */
export interface Cover {
/** 封面高度 */
height: number;
/** 封面宽度 */
width: number;
/** 默认URL */
url_default: string;
/** 预览URL */
url_pre: string;
}
/** 角标信息 */
export interface CornerTagInfo {
/** TODO: 角标类型如publish_time发布时间 */
type: 'publish_time' | string;
/** 角标文本 */
text: string;
}
/** 笔记卡片 */
export interface NoteCard {
/** 显示标题 */
display_title: string;
/** 用户信息 */
user: NoteUser;
/** 交互信息 */
interact_info: InteractInfo;
/** 封面 */
cover: Cover;
/** 图片列表 */
image_list: Image[];
/** 角标信息列表 */
corner_tag_info: CornerTagInfo[];
/** TODO: 笔记类型如normal普通 */
type: 'normal' | 'video';
}
/** 笔记 */
export interface Note {
/** 笔记ID */
id: string;
/** 模型类型如note笔记 */
model_type: string;
/** 笔记卡片 */
note_card: NoteCard;
/** 安全令牌 */
xsec_token: string;
}
}
export namespace XHS {
export interface ResultList<T = NoteCard> {
hasMore: boolean;
items: T[];
}
}