Skip to content

Commit 24f643a

Browse files
jun0315jackwener
andauthored
feat(xianyu): add inbox, messages, and reply commands (#1639)
* fix: tighten internal callback types * feat(xianyu): add private message commands * fix(xianyu): harden IM command contracts --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 1f30a90 commit 24f643a

15 files changed

Lines changed: 1049 additions & 129 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ To load the source Browser Bridge extension:
261261
| **yuanbao** | `new` `ask` |
262262
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
263263
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
264-
| **xianyu** | `search` `item` `chat` `publish` |
264+
| **xianyu** | `search` `item` `inbox` `messages` `chat` `reply` `publish` |
265265
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
266266
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` |
267267
| **uiverse** | `code` `preview` |

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ npm link
314314
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
315315
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
316316
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
317-
| **xianyu** | `search` `item` `chat` `publish` | 浏览器 |
317+
| **xianyu** | `search` `item` `inbox` `messages` `chat` `reply` `publish` | 浏览器 |
318318
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
319319
| **yuanbao** | `new` `ask` | 浏览器 |
320320

cli-manifest.json

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26477,6 +26477,54 @@
2647726477
"sourceFile": "xianyu/chat.js",
2647826478
"navigateBefore": false
2647926479
},
26480+
{
26481+
"site": "xianyu",
26482+
"name": "inbox",
26483+
"description": "列出闲鱼最近私信会话",
26484+
"access": "read",
26485+
"domain": "www.goofish.com",
26486+
"strategy": "cookie",
26487+
"browser": true,
26488+
"args": [
26489+
{
26490+
"name": "limit",
26491+
"type": "int",
26492+
"default": 20,
26493+
"required": false,
26494+
"help": "Number of conversations to return"
26495+
},
26496+
{
26497+
"name": "unread-only",
26498+
"type": "bool",
26499+
"default": false,
26500+
"required": false,
26501+
"help": "Return only conversations with unread messages"
26502+
},
26503+
{
26504+
"name": "resolve-ids",
26505+
"type": "bool",
26506+
"default": false,
26507+
"required": false,
26508+
"help": "Click each visible conversation to resolve item_id and peer_user_id from the chat URL"
26509+
}
26510+
],
26511+
"columns": [
26512+
"rank",
26513+
"peer_name",
26514+
"peer_user_id",
26515+
"item_id",
26516+
"item_title",
26517+
"price",
26518+
"last_message",
26519+
"unread",
26520+
"unread_count",
26521+
"url"
26522+
],
26523+
"type": "js",
26524+
"modulePath": "xianyu/inbox.js",
26525+
"sourceFile": "xianyu/inbox.js",
26526+
"navigateBefore": false
26527+
},
2648026528
{
2648126529
"site": "xianyu",
2648226530
"name": "item",
@@ -26509,6 +26557,58 @@
2650926557
"sourceFile": "xianyu/item.js",
2651026558
"navigateBefore": false
2651126559
},
26560+
{
26561+
"site": "xianyu",
26562+
"name": "messages",
26563+
"description": "读取指定闲鱼私信会话的最近聊天内容",
26564+
"access": "read",
26565+
"domain": "www.goofish.com",
26566+
"strategy": "cookie",
26567+
"browser": true,
26568+
"args": [
26569+
{
26570+
"name": "item_id",
26571+
"type": "str",
26572+
"required": false,
26573+
"positional": true,
26574+
"help": "闲鱼商品 item_id"
26575+
},
26576+
{
26577+
"name": "user_id",
26578+
"type": "str",
26579+
"required": false,
26580+
"positional": true,
26581+
"help": "聊一聊对方的 user_id / peerUserId"
26582+
},
26583+
{
26584+
"name": "limit",
26585+
"type": "int",
26586+
"default": 50,
26587+
"required": false,
26588+
"help": "Number of visible messages to return"
26589+
},
26590+
{
26591+
"name": "rank",
26592+
"type": "int",
26593+
"default": 0,
26594+
"required": false,
26595+
"help": "Conversation rank from xianyu inbox; clicks the visible row instead of requiring IDs"
26596+
}
26597+
],
26598+
"columns": [
26599+
"index",
26600+
"peer_name",
26601+
"item_title",
26602+
"message",
26603+
"item_id",
26604+
"peer_user_id",
26605+
"url"
26606+
],
26607+
"type": "js",
26608+
"modulePath": "xianyu/messages.js",
26609+
"sourceFile": "xianyu/messages.js",
26610+
"navigateBefore": false
26611+
},
2651226612
{
2651326613
"site": "xianyu",
2651426614
"name": "publish",
@@ -26586,6 +26686,56 @@
2658626686
"sourceFile": "xianyu/publish.js",
2658726687
"navigateBefore": false
2658826688
},
26689+
{
26690+
"site": "xianyu",
26691+
"name": "reply",
26692+
"description": "回复指定闲鱼私信会话",
26693+
"access": "write",
26694+
"domain": "www.goofish.com",
26695+
"strategy": "cookie",
26696+
"browser": true,
26697+
"args": [
26698+
{
26699+
"name": "item_id",
26700+
"type": "str",
26701+
"required": false,
26702+
"positional": true,
26703+
"help": "闲鱼商品 item_id"
26704+
},
26705+
{
26706+
"name": "user_id",
26707+
"type": "str",
26708+
"required": false,
26709+
"positional": true,
26710+
"help": "聊一聊对方的 user_id / peerUserId"
26711+
},
26712+
{
26713+
"name": "text",
26714+
"type": "str",
26715+
"required": true,
26716+
"help": "Message text to send"
26717+
},
26718+
{
26719+
"name": "rank",
26720+
"type": "int",
26721+
"default": 0,
26722+
"required": false,
26723+
"help": "Conversation rank from xianyu inbox; clicks the visible row instead of requiring IDs"
26724+
}
26725+
],
26726+
"columns": [
26727+
"status",
26728+
"peer_name",
26729+
"item_title",
26730+
"price",
26731+
"location",
26732+
"message"
26733+
],
26734+
"type": "js",
26735+
"modulePath": "xianyu/reply.js",
26736+
"sourceFile": "xianyu/reply.js",
26737+
"navigateBefore": false
26738+
},
2658926739
{
2659026740
"site": "xianyu",
2659126741
"name": "search",

clis/xianyu/chat.js

Lines changed: 24 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,8 @@
1-
import { AuthRequiredError, selectorError } from '@jackwener/opencli/errors';
1+
import { AuthRequiredError, CommandExecutionError, selectorError } from '@jackwener/opencli/errors';
22
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
import { buildChatUrl, buildExtractChatStateEvaluate, buildSendMessageEvaluate, requireEvaluateObject } from './im.js';
34
import { normalizeNumericId } from './utils.js';
4-
function buildChatUrl(itemId, peerUserId) {
5-
return `https://www.goofish.com/im?itemId=${encodeURIComponent(itemId)}&peerUserId=${encodeURIComponent(peerUserId)}`;
6-
}
7-
function buildExtractChatStateEvaluate() {
8-
return `
9-
(() => {
10-
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
11-
const bodyText = document.body?.innerText || '';
12-
const requiresAuth = /请先登录|登录后/.test(bodyText);
135

14-
const textarea = document.querySelector('textarea');
15-
const normalizeBtn = (s) => (s || '').replace(/\\s+/g, '').trim();
16-
const sendButton = Array.from(document.querySelectorAll('button'))
17-
.find((btn) => normalizeBtn(btn.textContent || '') === '发送');
18-
const topbar = document.querySelector('[class*="message-topbar"]');
19-
const itemCard = Array.from(document.querySelectorAll('a[href*="/item?id="]'))
20-
.find((el) => el.closest('main'));
21-
const itemTitleNode =
22-
document.querySelector('[class*="container"] [class*="title"]')
23-
|| document.querySelector('[class*="item-main-info"] [class*="desc"]')
24-
|| document.querySelector('[class*="headSkuInfo"]')
25-
|| itemCard?.querySelector('[class*="title"]')
26-
|| itemCard?.previousElementSibling?.querySelector?.('[class*="title"]');
27-
28-
const messageRoot = document.querySelector('#message-list-scrollable');
29-
const visibleMessages = Array.from(
30-
(messageRoot || document).querySelectorAll('[class*="message"], [class*="msg"], [class*="bubble"]')
31-
).map((el) => clean(el.textContent || ''))
32-
.filter(Boolean)
33-
.filter((text) => !['发送', '闲鱼号', '立即购买'].includes(text))
34-
.filter((text) => !/^消息\\d*\\+?$/.test(text))
35-
.slice(-20);
36-
37-
return {
38-
requiresAuth,
39-
title: clean(document.title || ''),
40-
peer_name: clean(topbar?.querySelector('[class*="text1"]')?.textContent || ''),
41-
peer_masked_id: clean(topbar?.querySelector('[class*="text2"]')?.textContent || '').replace(/^\\(|\\)$/g, ''),
42-
item_title: clean(itemTitleNode?.textContent || ''),
43-
item_url: itemCard?.href || '',
44-
price: clean(itemCard?.querySelector('[class*="money"]')?.textContent || ''),
45-
location: clean(itemCard?.querySelector('[class*="delivery"] + [class*="delivery"], [class*="delivery"]:last-child')?.textContent || ''),
46-
can_input: Boolean(textarea && !textarea.disabled),
47-
can_send: Boolean(sendButton),
48-
visible_messages: visibleMessages,
49-
};
50-
})()
51-
`;
52-
}
53-
function buildSendMessageEvaluate(text) {
54-
return `
55-
(async () => {
56-
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
57-
const textarea = document.querySelector('textarea');
58-
if (!textarea || textarea.disabled) {
59-
return { ok: false, reason: 'input-not-found' };
60-
}
61-
62-
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
63-
if (!setter) {
64-
return { ok: false, reason: 'textarea-setter-not-found' };
65-
}
66-
67-
// Click textarea first to activate chat and trigger send button to appear
68-
textarea.click();
69-
textarea.focus();
70-
setter.call(textarea, ${JSON.stringify(text)});
71-
textarea.dispatchEvent(new Event('input', { bubbles: true }));
72-
textarea.dispatchEvent(new Event('change', { bubbles: true }));
73-
74-
// Poll up to 3s for send button (may appear after textarea interaction)
75-
const normalizeBtn = (s) => (s || '').replace(/\\s+/g, '').trim();
76-
let sendButton = null;
77-
for (let i = 0; i < 30; i++) {
78-
sendButton = Array.from(document.querySelectorAll('button'))
79-
.find((btn) => normalizeBtn(btn.textContent || '') === '发送');
80-
if (sendButton) break;
81-
await new Promise(r => setTimeout(r, 100));
82-
}
83-
if (!sendButton) {
84-
return { ok: false, reason: 'send-button-not-found' };
85-
}
86-
87-
sendButton.click();
88-
return { ok: true };
89-
})()
90-
`;
91-
}
926
cli({
937
site: 'xianyu',
948
name: 'chat',
@@ -111,7 +25,7 @@ cli({
11125
const text = String(kwargs.text || '').trim();
11226
await page.goto(url);
11327
await page.wait(2);
114-
const state = await page.evaluate(buildExtractChatStateEvaluate());
28+
const state = requireEvaluateObject(await page.evaluate(buildExtractChatStateEvaluate()), 'chat');
11529
if (state?.requiresAuth) {
11630
throw new AuthRequiredError('www.goofish.com', 'Xianyu chat requires a logged-in browser session');
11731
}
@@ -120,37 +34,38 @@ cli({
12034
}
12135
if (!text) {
12236
return [{
123-
status: 'ready',
124-
peer_name: state.peer_name || '',
125-
item_title: state.item_title || '',
126-
price: state.price || '',
127-
location: state.location || '',
128-
message: (state.visible_messages || []).slice(-1)[0] || '',
129-
peer_user_id: userId,
130-
item_id: itemId,
131-
url,
132-
item_url: state.item_url || '',
133-
}];
134-
}
135-
const sent = await page.evaluate(buildSendMessageEvaluate(text));
136-
if (!sent?.ok) {
137-
throw selectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
138-
}
139-
await page.wait(1);
140-
return [{
141-
status: 'sent',
37+
status: 'ready',
14238
peer_name: state.peer_name || '',
14339
item_title: state.item_title || '',
14440
price: state.price || '',
14541
location: state.location || '',
146-
message: text,
42+
message: (state.visible_messages || []).slice(-1)[0] || '',
14743
peer_user_id: userId,
14844
item_id: itemId,
14945
url,
15046
item_url: state.item_url || '',
15147
}];
48+
}
49+
const sent = requireEvaluateObject(await page.evaluate(buildSendMessageEvaluate(text)), 'chat send');
50+
if (!sent?.ok) {
51+
throw new CommandExecutionError(`Xianyu chat did not observe the sent message: ${sent?.reason || 'unknown-reason'}`);
52+
}
53+
await page.wait(1);
54+
return [{
55+
status: 'sent',
56+
peer_name: state.peer_name || '',
57+
item_title: state.item_title || '',
58+
price: state.price || '',
59+
location: state.location || '',
60+
message: text,
61+
peer_user_id: userId,
62+
item_id: itemId,
63+
url,
64+
item_url: state.item_url || '',
65+
}];
15266
},
15367
});
68+
15469
export const __test__ = {
15570
normalizeNumericId,
15671
buildChatUrl,

clis/xianyu/chat.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ describe('xianyu chat helpers', () => {
4141
const result = await runBrowserScript(`
4242
<main>
4343
<textarea></textarea>
44+
<div id="message-list-scrollable"></div>
4445
</main>
4546
`, __test__.buildSendMessageEvaluate('还在吗?'), {
4647
beforeEval(window) {
@@ -53,6 +54,10 @@ describe('xianyu chat helpers', () => {
5354
button.textContent = '发 送';
5455
button.addEventListener('click', () => {
5556
sendClicked = true;
57+
const row = window.document.createElement('div');
58+
row.className = 'message-row';
59+
row.innerHTML = '<div class="message-text">还在吗?</div>';
60+
window.document.querySelector('#message-list-scrollable').append(row);
5661
});
5762
window.document.body.append(button);
5863
});

0 commit comments

Comments
 (0)