feat(jike): add Jike adapter with 10 commands (#117)
Add comprehensive Jike (即刻) adapter covering read and write operations. Read commands: - user: user posts via m.okjike.com SSR JSON - topic: topic/circle posts via m.okjike.com SSR JSON - post: post detail with comments via m.okjike.com SSR JSON - feed: home timeline via React fiber tree extraction - search: search posts via React fiber tree extraction - notifications: notification list via DOM innerText parsing Write commands (Strategy.UI, browser DOM automation): - create: publish post via inline compose box - comment: comment on post via contenteditable paste - like: like post via _likeButton_ div click - repost: repost via action bar → popover menu → confirm Implementation details: - Three data extraction strategies: SSR JSON, React fiber, DOM manipulation - Shared JikePost interface and getPostData helper in shared.ts - All evaluate blocks include try/catch error handling - Two rounds of parallel Claude + Codex code review applied
This commit is contained in:
1 parent
44f0bbe94d
commit
d6e0aa120b
11 files changed
+935
No files matched your search
@@ -0,0 +1,113 @@
|
|||||||
|
import { cli, Strategy } from '../../registry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评论即刻帖子
|
||||||
|
*
|
||||||
|
* 帖子详情页有评论输入框(contenteditable 或 textarea),
|
||||||
|
* 填入文本后点击"回复"或"发布"按钮提交。
|
||||||
|
*/
|
||||||
|
|
||||||
|
cli({
|
||||||
|
site: 'jike',
|
||||||
|
name: 'comment',
|
||||||
|
description: '评论即刻帖子',
|
||||||
|
domain: 'web.okjike.com',
|
||||||
|
strategy: Strategy.UI,
|
||||||
|
browser: true,
|
||||||
|
args: [
|
||||||
|
{ name: 'id', type: 'string', required: true, help: '帖子 ID' },
|
||||||
|
{ name: 'text', type: 'string', required: true, help: '评论内容' },
|
||||||
|
],
|
||||||
|
columns: ['status', 'message'],
|
||||||
|
func: async (page, kwargs) => {
|
||||||
|
await page.goto(`https://web.okjike.com/originalPost/${kwargs.id}`);
|
||||||
|
await page.wait(5);
|
||||||
|
|
||||||
|
// 1. 找到评论输入框并填入文本
|
||||||
|
const inputResult = await page.evaluate(`(async () => {
|
||||||
|
try {
|
||||||
|
const textToInsert = ${JSON.stringify(kwargs.text)};
|
||||||
|
|
||||||
|
// 优先在评论区容器内找 contenteditable,避免误选页面其他编辑器;
|
||||||
|
// 若评论区 class 名变更则回退到全页查找
|
||||||
|
const editor =
|
||||||
|
document.querySelector('[class*="_comment_"] [contenteditable="true"]') ||
|
||||||
|
document.querySelector('[contenteditable="true"]');
|
||||||
|
if (editor) {
|
||||||
|
editor.focus();
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.setData('text/plain', textToInsert);
|
||||||
|
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||||
|
clipboardData: dt, bubbles: true, cancelable: true,
|
||||||
|
}));
|
||||||
|
await new Promise(r => setTimeout(r, 800));
|
||||||
|
if (editor.textContent?.length > 0) {
|
||||||
|
return { ok: true, message: 'contenteditable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:textarea(带评论相关 placeholder)
|
||||||
|
const textareas = document.querySelectorAll('textarea');
|
||||||
|
for (const ta of textareas) {
|
||||||
|
const ph = ta.getAttribute('placeholder') || '';
|
||||||
|
if (ph.includes('评论') || ph.includes('回复') || ph.includes('说点什么')) {
|
||||||
|
ta.focus();
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype, 'value'
|
||||||
|
)?.set;
|
||||||
|
setter?.call(ta, textToInsert);
|
||||||
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
return { ok: true, message: 'textarea' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:任意 textarea
|
||||||
|
if (textareas.length > 0) {
|
||||||
|
const ta = textareas[0];
|
||||||
|
ta.focus();
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype, 'value'
|
||||||
|
)?.set;
|
||||||
|
setter?.call(ta, textToInsert);
|
||||||
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
return { ok: true, message: 'textarea-fallback' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, message: '未找到评论输入框' };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e.toString() };
|
||||||
|
}
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
if (!inputResult.ok) {
|
||||||
|
return [{ status: 'failed', message: inputResult.message }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 点击"回复"或"发布"按钮
|
||||||
|
const submitResult = await page.evaluate(`(async () => {
|
||||||
|
try {
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
const btns = Array.from(document.querySelectorAll('button')).filter(btn => {
|
||||||
|
const text = btn.textContent?.trim() || '';
|
||||||
|
return (text === '回复' || text === '发布' || text === '发送' || text === '评论') && !btn.disabled;
|
||||||
|
});
|
||||||
|
if (btns.length === 0) {
|
||||||
|
return { ok: false, message: '未找到可用的回复按钮(可能因内容为空而禁用)' };
|
||||||
|
}
|
||||||
|
btns[0].click();
|
||||||
|
return { ok: true, message: '评论发布成功' };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e.toString() };
|
||||||
|
}
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
if (submitResult.ok) await page.wait(3);
|
||||||
|
|
||||||
|
return [{
|
||||||
|
status: submitResult.ok ? 'success' : 'failed',
|
||||||
|
message: submitResult.message,
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { cli, Strategy } from '../../registry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布即刻动态
|
||||||
|
*
|
||||||
|
* 即刻首页 /following 顶部有内联发帖框("分享你的想法..."),
|
||||||
|
* 直接在其中输入文本,点击"发送"按钮即可发布。
|
||||||
|
*/
|
||||||
|
|
||||||
|
cli({
|
||||||
|
site: 'jike',
|
||||||
|
name: 'create',
|
||||||
|
description: '发布即刻动态',
|
||||||
|
domain: 'web.okjike.com',
|
||||||
|
strategy: Strategy.UI,
|
||||||
|
browser: true,
|
||||||
|
args: [
|
||||||
|
{ name: 'text', type: 'string', required: true, help: '动态正文内容' },
|
||||||
|
],
|
||||||
|
columns: ['status', 'message'],
|
||||||
|
func: async (page, kwargs) => {
|
||||||
|
// 1. 导航到首页(有内联发帖框)
|
||||||
|
await page.goto('https://web.okjike.com');
|
||||||
|
await page.wait(5);
|
||||||
|
|
||||||
|
// 2. 在发帖框中输入文本
|
||||||
|
const textResult = await page.evaluate(`(async () => {
|
||||||
|
try {
|
||||||
|
const textToInsert = ${JSON.stringify(kwargs.text)};
|
||||||
|
|
||||||
|
// 首页发帖框在 _postForm_ 容器内,查找其中的 contenteditable
|
||||||
|
const form = document.querySelector('[class*="_postForm_"]');
|
||||||
|
const editor = form
|
||||||
|
? form.querySelector('[contenteditable="true"]')
|
||||||
|
: document.querySelector('[contenteditable="true"]');
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
editor.focus();
|
||||||
|
// 用 ClipboardEvent paste 触发 React 状态更新
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.setData('text/plain', textToInsert);
|
||||||
|
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||||
|
clipboardData: dt, bubbles: true, cancelable: true,
|
||||||
|
}));
|
||||||
|
await new Promise(r => setTimeout(r, 800));
|
||||||
|
|
||||||
|
// 检查是否成功插入
|
||||||
|
const inserted = editor.textContent || '';
|
||||||
|
if (inserted.length > 0) {
|
||||||
|
return { ok: true, message: 'contenteditable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:textarea
|
||||||
|
const textarea = form
|
||||||
|
? form.querySelector('textarea')
|
||||||
|
: document.querySelector('textarea');
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
textarea.focus();
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype, 'value'
|
||||||
|
)?.set;
|
||||||
|
setter?.call(textarea, textToInsert);
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
return { ok: true, message: 'textarea' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, message: '未找到发帖输入框' };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e.toString() };
|
||||||
|
}
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
if (!textResult.ok) {
|
||||||
|
return [{ status: 'failed', message: textResult.message }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 点击"发送"按钮
|
||||||
|
const submitResult = await page.evaluate(`(async () => {
|
||||||
|
try {
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
|
// 即刻首页发帖框的按钮文字为"发送"
|
||||||
|
const candidates = [
|
||||||
|
...Array.from(document.querySelectorAll('button')).filter(btn => {
|
||||||
|
const text = btn.textContent?.trim() || '';
|
||||||
|
return text === '发送' || text === '发布';
|
||||||
|
}),
|
||||||
|
].filter(el => el && !el.disabled);
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return { ok: false, message: '未找到可用的发送按钮(按钮可能因内容为空而禁用)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates[0].click();
|
||||||
|
return { ok: true, message: '动态发布成功' };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e.toString() };
|
||||||
|
}
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
if (submitResult.ok) {
|
||||||
|
await page.wait(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
status: submitResult.ok ? 'success' : 'failed',
|
||||||
|
message: submitResult.message,
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { cli, Strategy } from '../../registry.js';
|
||||||
|
import { JikePost, getPostDataJs } from './shared.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 即刻首页动态流适配器
|
||||||
|
*
|
||||||
|
* 策略:导航到 web.okjike.com/following(需登录),
|
||||||
|
* 通过 React fiber 树提取帖子数据。
|
||||||
|
*/
|
||||||
|
|
||||||
|
cli({
|
||||||
|
site: 'jike',
|
||||||
|
name: 'feed',
|
||||||
|
description: '即刻首页动态流',
|
||||||
|
domain: 'web.okjike.com',
|
||||||
|
strategy: Strategy.COOKIE,
|
||||||
|
browser: true,
|
||||||
|
args: [
|
||||||
|
{ name: 'limit', type: 'int', default: 20 },
|
||||||
|
],
|
||||||
|
columns: ['author', 'content', 'likes', 'comments', 'time', 'url'],
|
||||||
|
func: async (page, kwargs) => {
|
||||||
|
const limit = kwargs.limit || 20;
|
||||||
|
|
||||||
|
// 1. 导航到即刻首页,等待 SPA 重定向到 /following
|
||||||
|
await page.goto('https://web.okjike.com');
|
||||||
|
await page.wait(5);
|
||||||
|
|
||||||
|
// 2. 通过 React fiber 提取帖子数据
|
||||||
|
const extract = async (): Promise<JikePost[]> => {
|
||||||
|
return (await page.evaluate(`(() => {
|
||||||
|
${getPostDataJs}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const elements = document.querySelectorAll('[class*="_post_"]');
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
const data = getPostData(el);
|
||||||
|
if (!data || !data.id || seen.has(data.id)) continue;
|
||||||
|
seen.add(data.id);
|
||||||
|
|
||||||
|
// 转发帖的正文可能为空,取 target(原帖)的内容作 fallback
|
||||||
|
const author = data.user?.screenName || data.target?.user?.screenName || '';
|
||||||
|
const content = data.content || data.target?.content || '';
|
||||||
|
|
||||||
|
// 跳过无内容且无作者的条目(如 PERSONAL_UPDATE)
|
||||||
|
if (!author && !content) continue;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
author,
|
||||||
|
content: content.replace(/\\n/g, ' ').slice(0, 120),
|
||||||
|
likes: data.likeCount || 0,
|
||||||
|
comments: data.commentCount || 0,
|
||||||
|
time: data.actionTime || data.createdAt || '',
|
||||||
|
url: 'https://web.okjike.com/originalPost/' + data.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
})()`)) as JikePost[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let posts = await extract();
|
||||||
|
|
||||||
|
// 3. 如果数量不足,自动滚动加载更多
|
||||||
|
if (posts.length < limit) {
|
||||||
|
await page.autoScroll({ times: Math.ceil(limit / 10), delayMs: 2000 });
|
||||||
|
posts = await extract();
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts.slice(0, limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { cli, Strategy } from '../../registry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞即刻帖子
|
||||||
|
*
|
||||||
|
* 即刻帖子详情页的操作栏是 div 元素(非 button),
|
||||||
|
* 点赞按钮可通过 class 前缀 _likeButton_ 定位。
|
||||||
|
*/
|
||||||
|
|
||||||
|
cli({
|
||||||
|
site: 'jike',
|
||||||
|
name: 'like',
|
||||||
|
description: '点赞即刻帖子',
|
||||||
|
domain: 'web.okjike.com',
|
||||||
|
strategy: Strategy.UI,
|
||||||
|
browser: true,
|
||||||
|
args: [
|
||||||
|
{ name: 'id', type: 'string', required: true, help: '帖子 ID' },
|
||||||
|
],
|
||||||
|
columns: ['status', 'message'],
|
||||||
|
func: async (page, kwargs) => {
|
||||||
|
// 1. 导航到帖子详情页
|
||||||
|
await page.goto(`https://web.okjike.com/originalPost/${kwargs.id}`);
|
||||||
|
await page.wait(5);
|
||||||
|
|
||||||
|
// 2. 找到点赞按钮并点击
|
||||||
|
const result = await page.evaluate(`(async () => {
|
||||||
|
try {
|
||||||
|
// 点赞按钮:class 包含 _likeButton_,在 _actions_ 容器内
|
||||||
|
const likeBtn = document.querySelector('[class*="_likeButton_"]');
|
||||||
|
if (!likeBtn) {
|
||||||
|
return { ok: false, message: '未找到点赞按钮' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已点赞(已赞按钮带有 _liked_ 类)
|
||||||
|
const cls = likeBtn.className || '';
|
||||||
|
if (cls.includes('_liked')) {
|
||||||
|
return { ok: true, message: '该帖子已赞过' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录点击前的类名
|
||||||
|
const beforeCls = likeBtn.className;
|
||||||
|
|
||||||
|
likeBtn.click();
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
|
||||||
|
// 验证:类名变化表示点赞成功
|
||||||
|
const afterCls = likeBtn.className;
|
||||||
|
if (afterCls !== beforeCls) {
|
||||||
|
return { ok: true, message: '点赞成功' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类名未变化,无法确认点赞是否成功
|
||||||
|
return { ok: false, message: '点赞状态未确认,请手动检查' };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e.toString() };
|
||||||
|
}
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
return [{
|
||||||
|
status: result.ok ? 'success' : 'failed',
|
||||||
|
message: result.message,
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { cli, Strategy } from '../../registry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 即刻通知适配器
|
||||||
|
*
|
||||||
|
* 策略:直接导航到 web.okjike.com/notification,
|
||||||
|
* 优先用 React fiber 树提取通知数据,失败时回退到 DOM 文本提取。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 即刻通知的通用字段
|
||||||
|
interface JikeNotification {
|
||||||
|
type: string;
|
||||||
|
user: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将通知类型代码映射为中文标签
|
||||||
|
function resolveActionLabel(type: string): string {
|
||||||
|
if (!type) return '通知';
|
||||||
|
const upper = type.toUpperCase();
|
||||||
|
if (upper.includes('LIKE')) return '赞了你';
|
||||||
|
if (upper.includes('COMMENT')) return '评论了你';
|
||||||
|
if (upper.includes('FOLLOW')) return '关注了你';
|
||||||
|
if (upper.includes('REPOST')) return '转发了你';
|
||||||
|
if (upper.includes('MENTION')) return '提到了你';
|
||||||
|
if (upper.includes('REPLY')) return '回复了你';
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
cli({
|
||||||
|
site: 'jike',
|
||||||
|
name: 'notifications',
|
||||||
|
description: '即刻通知',
|
||||||
|
domain: 'web.okjike.com',
|
||||||
|
strategy: Strategy.COOKIE,
|
||||||
|
browser: true,
|
||||||
|
args: [
|
||||||
|
{ name: 'limit', type: 'int', default: 20 },
|
||||||
|
],
|
||||||
|
columns: ['type', 'user', 'content', 'time'],
|
||||||
|
func: async (page, kwargs) => {
|
||||||
|
const limit = (kwargs.limit as number) || 20;
|
||||||
|
|
||||||
|
// 1. 直接导航到通知页
|
||||||
|
await page.goto('https://web.okjike.com/notification');
|
||||||
|
await page.wait(5);
|
||||||
|
|
||||||
|
// 3. 优先用 React fiber 提取通知数据
|
||||||
|
// 通知 fiber 数据结构与帖子不同,需查找含 type + user 字段的 props
|
||||||
|
const fiberResults = (await page.evaluate(`(() => {
|
||||||
|
// 从 React fiber 树中提取通知数据,向上最多走 15 层
|
||||||
|
function getNotificationData(element) {
|
||||||
|
for (const key of Object.keys(element)) {
|
||||||
|
if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) {
|
||||||
|
let fiber = element[key];
|
||||||
|
for (let i = 0; i < 15 && fiber; i++) {
|
||||||
|
const props = fiber.memoizedProps || fiber.pendingProps;
|
||||||
|
if (props && props.data) {
|
||||||
|
const d = props.data;
|
||||||
|
// 通知条目特征:含 type/actionType 字段,以及来源用户字段
|
||||||
|
if (d.type || d.actionType || d.notificationType) return d;
|
||||||
|
}
|
||||||
|
fiber = fiber.return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
// 通知页使用 _item_ 类前缀作为条目容器
|
||||||
|
const elements = Array.from(document.querySelectorAll('[class*="_item_"]'));
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
const data = getNotificationData(el);
|
||||||
|
if (!data) continue;
|
||||||
|
|
||||||
|
const actionType = data.actionType || data.type || data.notificationType || '';
|
||||||
|
const fromUser =
|
||||||
|
(data.sourceUser && data.sourceUser.screenName) ||
|
||||||
|
(data.user && data.user.screenName) ||
|
||||||
|
(data.actionUser && data.actionUser.screenName) ||
|
||||||
|
(data.actor && data.actor.screenName) ||
|
||||||
|
'';
|
||||||
|
const targetContent =
|
||||||
|
(data.targetPost && data.targetPost.content) ||
|
||||||
|
(data.post && data.post.content) ||
|
||||||
|
'';
|
||||||
|
const commentContent =
|
||||||
|
(data.comment && data.comment.content) ||
|
||||||
|
data.commentContent ||
|
||||||
|
'';
|
||||||
|
const content = commentContent || targetContent;
|
||||||
|
const time = data.createdAt || data.updatedAt || '';
|
||||||
|
|
||||||
|
// 用 user+time+type 去重,避免同一用户同一时间不同类型通知被合并
|
||||||
|
const key = fromUser + '\x00' + time + '\x00' + (data.type || data.actionType || '');
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
if (!fromUser && !content) continue;
|
||||||
|
|
||||||
|
results.push({ actionType, fromUser, content, time });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
})()`)) as Array<{ actionType: string; fromUser: string; content: string; time: string }>;
|
||||||
|
|
||||||
|
// 4. fiber 提取成功,映射类型标签后返回
|
||||||
|
if (fiberResults.length > 0) {
|
||||||
|
const notifications: JikeNotification[] = fiberResults.map((r) => ({
|
||||||
|
type: resolveActionLabel(r.actionType),
|
||||||
|
user: r.fromUser,
|
||||||
|
content: r.content.replace(/\n/g, ' ').slice(0, 100),
|
||||||
|
time: r.time,
|
||||||
|
}));
|
||||||
|
return notifications.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 回退:解析通知条目的 innerText(格式: 用户名\n操作描述\n日期)
|
||||||
|
await page.autoScroll({ times: Math.ceil(limit / 10), delayMs: 2000 });
|
||||||
|
|
||||||
|
const domResults = (await page.evaluate(`(() => {
|
||||||
|
const results = [];
|
||||||
|
const items = document.querySelectorAll('[class*="_item_"]');
|
||||||
|
|
||||||
|
// 动作关键词,用于定位通知类型行
|
||||||
|
// 长模式优先匹配,避免"赞了你"截断"赞了你的动态"
|
||||||
|
const actionPatterns = [
|
||||||
|
'赞了你的动态', '赞了你的评论', '赞了你的转发',
|
||||||
|
'评论了你的动态', '评论了你的转发',
|
||||||
|
'回复了你的评论', '回复了你',
|
||||||
|
'转发了你的动态',
|
||||||
|
'提到了你', '关注了你',
|
||||||
|
'赞了你', '评论了你', '转发了你',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const text = item.innerText || '';
|
||||||
|
const lines = text.split('\\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
if (lines.length < 2) continue;
|
||||||
|
|
||||||
|
// 策略:在全文中搜索动作关键词来定位类型
|
||||||
|
let type = '';
|
||||||
|
let user = '';
|
||||||
|
let time = '';
|
||||||
|
let content = '';
|
||||||
|
|
||||||
|
const fullText = lines.join(' ');
|
||||||
|
for (const pattern of actionPatterns) {
|
||||||
|
const idx = fullText.indexOf(pattern);
|
||||||
|
if (idx >= 0) {
|
||||||
|
// 关键词前面是用户名(可能多人用、分隔)
|
||||||
|
user = fullText.slice(0, idx).replace(/[、,]/g, ' ').trim();
|
||||||
|
type = pattern;
|
||||||
|
// 关键词后面可能是时间和原帖内容
|
||||||
|
const rest = fullText.slice(idx + pattern.length).trim();
|
||||||
|
// 时间通常以数字或"刚刚"/"分钟前"等开头
|
||||||
|
const timeMatch = rest.match(/^[\\S]*?(?:刚刚|\\d+.*?前|\\d{4}\\/\\d{2}\\/\\d{2})/);
|
||||||
|
time = timeMatch ? timeMatch[0] : '';
|
||||||
|
content = rest.slice(time.length).trim().slice(0, 100);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没匹配到动作关键词,回退到简单行序
|
||||||
|
if (!type) {
|
||||||
|
user = lines[0] || '';
|
||||||
|
type = lines[1] || '';
|
||||||
|
time = lines[2] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user && !type) continue;
|
||||||
|
results.push({ type, user, content, time });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
})()`)) as JikeNotification[];
|
||||||
|
|
||||||
|
return domResults.slice(0, limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
site: jike
|
||||||
|
name: post
|
||||||
|
description: 即刻帖子详情及评论
|
||||||
|
domain: m.okjike.com
|
||||||
|
browser: true
|
||||||
|
|
||||||
|
args:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Post ID (from post URL)
|
||||||
|
|
||||||
|
pipeline:
|
||||||
|
- navigate: https://m.okjike.com/originalPosts/${{ args.id }}
|
||||||
|
|
||||||
|
# 从 Next.js SSR 内嵌 JSON 中提取帖子和评论
|
||||||
|
- evaluate: |
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const el = document.querySelector('script[type="application/json"]');
|
||||||
|
if (!el) return [];
|
||||||
|
const data = JSON.parse(el.textContent);
|
||||||
|
const pageProps = data?.props?.pageProps || {};
|
||||||
|
const post = pageProps.post || {};
|
||||||
|
const comments = pageProps.comments || [];
|
||||||
|
|
||||||
|
const result = [{
|
||||||
|
type: 'post',
|
||||||
|
author: post.user?.screenName || '',
|
||||||
|
content: post.content || '',
|
||||||
|
likes: post.likeCount || 0,
|
||||||
|
time: post.createdAt || '',
|
||||||
|
}];
|
||||||
|
|
||||||
|
for (const c of comments) {
|
||||||
|
result.push({
|
||||||
|
type: 'comment',
|
||||||
|
author: c.user?.screenName || '',
|
||||||
|
content: (c.content || '').replace(/\n/g, ' '),
|
||||||
|
likes: c.likeCount || 0,
|
||||||
|
time: c.createdAt || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
- map:
|
||||||
|
type: ${{ item.type }}
|
||||||
|
author: ${{ item.author }}
|
||||||
|
content: ${{ item.content }}
|
||||||
|
likes: ${{ item.likes }}
|
||||||
|
time: ${{ item.time }}
|
||||||
|
|
||||||
|
columns: [type, author, content, likes, time]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { cli, Strategy } from '../../registry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转发即刻帖子
|
||||||
|
*
|
||||||
|
* 操作栏转发按钮点击后弹出 Popover 菜单,
|
||||||
|
* 选择"转发动态"后弹出编辑器弹窗(可添加附言),
|
||||||
|
* 再点击"发布"确认转发。
|
||||||
|
*/
|
||||||
|
|
||||||
|
cli({
|
||||||
|
site: 'jike',
|
||||||
|
name: 'repost',
|
||||||
|
description: '转发即刻帖子',
|
||||||
|
domain: 'web.okjike.com',
|
||||||
|
strategy: Strategy.UI,
|
||||||
|
browser: true,
|
||||||
|
args: [
|
||||||
|
{ name: 'id', type: 'string', required: true, help: '帖子 ID' },
|
||||||
|
{ name: 'text', type: 'string', required: false, help: '转发附言(可选)' },
|
||||||
|
],
|
||||||
|
columns: ['status', 'message'],
|
||||||
|
func: async (page, kwargs) => {
|
||||||
|
await page.goto(`https://web.okjike.com/originalPost/${kwargs.id}`);
|
||||||
|
await page.wait(5);
|
||||||
|
|
||||||
|
// 1. 点击操作栏中的转发按钮(第三个子元素)
|
||||||
|
const clickResult = await page.evaluate(`(async () => {
|
||||||
|
try {
|
||||||
|
const actions = document.querySelector('[class*="_actions_"]');
|
||||||
|
if (!actions) return { ok: false, message: '未找到操作栏' };
|
||||||
|
const children = Array.from(actions.children).filter(c => c.offsetHeight > 0);
|
||||||
|
if (!children[2]) return { ok: false, message: '未找到转发按钮' };
|
||||||
|
// 注意:按位置定位,即刻操作栏顺序变化时需调整
|
||||||
|
children[2].click();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e.toString() };
|
||||||
|
}
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
if (!clickResult.ok) {
|
||||||
|
return [{ status: 'failed', message: clickResult.message }];
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.wait(1);
|
||||||
|
|
||||||
|
// 2. 在弹出菜单中点击"转发动态"
|
||||||
|
const menuResult = await page.evaluate(`(async () => {
|
||||||
|
try {
|
||||||
|
const btn = Array.from(document.querySelectorAll('button')).find(
|
||||||
|
b => b.textContent?.trim() === '转发动态'
|
||||||
|
);
|
||||||
|
if (!btn) return { ok: false, message: '未找到"转发动态"菜单项' };
|
||||||
|
btn.click();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e.toString() };
|
||||||
|
}
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
if (!menuResult.ok) {
|
||||||
|
return [{ status: 'failed', message: menuResult.message }];
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.wait(2);
|
||||||
|
|
||||||
|
// 3. 若有附言,在弹窗编辑器中填入
|
||||||
|
if (kwargs.text) {
|
||||||
|
const textResult = await page.evaluate(`(async () => {
|
||||||
|
try {
|
||||||
|
const textToInsert = ${JSON.stringify(kwargs.text)};
|
||||||
|
const editor = document.querySelector('[contenteditable="true"]');
|
||||||
|
if (!editor) return { ok: false, message: '未找到附言输入框' };
|
||||||
|
editor.focus();
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.setData('text/plain', textToInsert);
|
||||||
|
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||||
|
clipboardData: dt, bubbles: true, cancelable: true,
|
||||||
|
}));
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
return { ok: true };
|
||||||
|
} catch(e) { return { ok: false, message: '附言写入失败: ' + e.toString() }; }
|
||||||
|
})()`);
|
||||||
|
if (!textResult.ok) {
|
||||||
|
return [{ status: 'failed', message: textResult.message }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 点击"发送"按钮确认转发
|
||||||
|
const confirmResult = await page.evaluate(`(async () => {
|
||||||
|
try {
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
const btn = Array.from(document.querySelectorAll('button')).find(b => {
|
||||||
|
const text = b.textContent?.trim() || '';
|
||||||
|
// 不匹配"转发动态",避免重复触发 Popover 菜单项
|
||||||
|
return (text === '发送' || text === '发布') && !b.disabled;
|
||||||
|
});
|
||||||
|
if (!btn) return { ok: false, message: '未找到发送按钮' };
|
||||||
|
btn.click();
|
||||||
|
return { ok: true, message: '转发成功' };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e.toString() };
|
||||||
|
}
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
if (confirmResult.ok) await page.wait(3);
|
||||||
|
|
||||||
|
return [{
|
||||||
|
status: confirmResult.ok ? 'success' : 'failed',
|
||||||
|
message: confirmResult.message,
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { cli, Strategy } from '../../registry.js';
|
||||||
|
import { JikePost, getPostDataJs } from './shared.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 即刻搜索适配器
|
||||||
|
*
|
||||||
|
* 策略:直接导航到 web.okjike.com 搜索页,
|
||||||
|
* 通过 React fiber 树提取帖子数据。
|
||||||
|
*/
|
||||||
|
|
||||||
|
cli({
|
||||||
|
site: 'jike',
|
||||||
|
name: 'search',
|
||||||
|
description: '搜索即刻帖子',
|
||||||
|
domain: 'web.okjike.com',
|
||||||
|
strategy: Strategy.COOKIE,
|
||||||
|
browser: true,
|
||||||
|
args: [
|
||||||
|
{ name: 'keyword', type: 'string', required: true },
|
||||||
|
{ name: 'limit', type: 'int', default: 20 },
|
||||||
|
],
|
||||||
|
columns: ['author', 'content', 'likes', 'comments', 'time', 'url'],
|
||||||
|
func: async (page, kwargs) => {
|
||||||
|
const keyword = kwargs.keyword as string;
|
||||||
|
const limit = (kwargs.limit as number) || 20;
|
||||||
|
|
||||||
|
// 1. 直接导航到搜索页
|
||||||
|
const encodedKeyword = encodeURIComponent(keyword);
|
||||||
|
await page.goto(`https://web.okjike.com/search?q=${encodedKeyword}`);
|
||||||
|
await page.wait(5);
|
||||||
|
|
||||||
|
// 2. 通过 React fiber 提取帖子数据
|
||||||
|
const extract = async (): Promise<JikePost[]> => {
|
||||||
|
return (await page.evaluate(`(() => {
|
||||||
|
${getPostDataJs}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const elements = document.querySelectorAll('[class*="_post_"], [class*="_postItem_"]');
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
const data = getPostData(el);
|
||||||
|
if (!data || !data.id || seen.has(data.id)) continue;
|
||||||
|
seen.add(data.id);
|
||||||
|
|
||||||
|
const author = data.user?.screenName || data.target?.user?.screenName || '';
|
||||||
|
const content = data.content || data.target?.content || '';
|
||||||
|
if (!author && !content) continue;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
author,
|
||||||
|
content: content.replace(/\\n/g, ' ').slice(0, 120),
|
||||||
|
likes: data.likeCount || 0,
|
||||||
|
comments: data.commentCount || 0,
|
||||||
|
time: data.actionTime || data.createdAt || '',
|
||||||
|
url: 'https://web.okjike.com/originalPost/' + data.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
})()`)) as JikePost[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let posts = await extract();
|
||||||
|
|
||||||
|
// 3. 数量不足时自动滚动加载更多
|
||||||
|
if (posts.length < limit) {
|
||||||
|
await page.autoScroll({ times: Math.ceil(limit / 10), delayMs: 2000 });
|
||||||
|
posts = await extract();
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts.slice(0, limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 即刻适配器公共定义
|
||||||
|
*
|
||||||
|
* JikePost 接口和 getPostData 函数在 feed.ts / search.ts 中复用,
|
||||||
|
* 统一维护于此文件避免重复。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 即刻帖子的通用字段
|
||||||
|
export interface JikePost {
|
||||||
|
author: string;
|
||||||
|
content: string;
|
||||||
|
likes: number;
|
||||||
|
comments: number;
|
||||||
|
time: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入浏览器 evaluate 的 JS 函数字符串。
|
||||||
|
* 从 React fiber 树中向上最多走 10 层,找到含 id 字段的 props.data。
|
||||||
|
*/
|
||||||
|
export const getPostDataJs = `
|
||||||
|
function getPostData(element) {
|
||||||
|
for (const key of Object.keys(element)) {
|
||||||
|
if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) {
|
||||||
|
let fiber = element[key];
|
||||||
|
for (let i = 0; i < 10 && fiber; i++) {
|
||||||
|
const props = fiber.memoizedProps || fiber.pendingProps;
|
||||||
|
if (props && props.data && props.data.id) return props.data;
|
||||||
|
fiber = fiber.return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
`.trim();
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
site: jike
|
||||||
|
name: topic
|
||||||
|
description: 即刻话题/圈子帖子
|
||||||
|
domain: m.okjike.com
|
||||||
|
browser: true
|
||||||
|
|
||||||
|
args:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Topic ID (from topic URL, e.g. 553870e8e4b0cafb0a1bef68)
|
||||||
|
limit:
|
||||||
|
type: int
|
||||||
|
default: 20
|
||||||
|
description: Number of posts
|
||||||
|
|
||||||
|
pipeline:
|
||||||
|
- navigate: https://m.okjike.com/topics/${{ args.id }}
|
||||||
|
|
||||||
|
# 从 Next.js SSR 内嵌 JSON 中提取话题帖子
|
||||||
|
- evaluate: |
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const el = document.querySelector('script[type="application/json"]');
|
||||||
|
if (!el) return [];
|
||||||
|
const data = JSON.parse(el.textContent);
|
||||||
|
const pageProps = data?.props?.pageProps || {};
|
||||||
|
const posts = pageProps.posts || [];
|
||||||
|
return posts.map(p => ({
|
||||||
|
content: (p.content || '').replace(/\n/g, ' ').slice(0, 80),
|
||||||
|
author: p.user?.screenName || '',
|
||||||
|
likes: p.likeCount || 0,
|
||||||
|
comments: p.commentCount || 0,
|
||||||
|
time: p.actionTime || p.createdAt || '',
|
||||||
|
id: p.id || '',
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
- map:
|
||||||
|
content: ${{ item.content }}
|
||||||
|
author: ${{ item.author }}
|
||||||
|
likes: ${{ item.likes }}
|
||||||
|
comments: ${{ item.comments }}
|
||||||
|
time: ${{ item.time }}
|
||||||
|
url: https://web.okjike.com/originalPost/${{ item.id }}
|
||||||
|
|
||||||
|
- limit: ${{ args.limit }}
|
||||||
|
|
||||||
|
columns: [content, author, likes, comments, time, url]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
site: jike
|
||||||
|
name: user
|
||||||
|
description: 即刻用户动态
|
||||||
|
domain: m.okjike.com
|
||||||
|
browser: true
|
||||||
|
|
||||||
|
args:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Username from profile URL (e.g. wenhao1996)
|
||||||
|
limit:
|
||||||
|
type: int
|
||||||
|
default: 20
|
||||||
|
description: Number of posts
|
||||||
|
|
||||||
|
pipeline:
|
||||||
|
- navigate: https://m.okjike.com/users/${{ args.username }}
|
||||||
|
|
||||||
|
# 从 Next.js SSR 内嵌 JSON 中提取用户动态
|
||||||
|
- evaluate: |
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const el = document.querySelector('script[type="application/json"]');
|
||||||
|
if (!el) return [];
|
||||||
|
const data = JSON.parse(el.textContent);
|
||||||
|
const posts = data?.props?.pageProps?.posts || [];
|
||||||
|
return posts.map(p => ({
|
||||||
|
content: (p.content || '').replace(/\n/g, ' ').slice(0, 80),
|
||||||
|
type: p.type === 'ORIGINAL_POST' ? 'post' : p.type === 'REPOST' ? 'repost' : p.type || '',
|
||||||
|
likes: p.likeCount || 0,
|
||||||
|
comments: p.commentCount || 0,
|
||||||
|
time: p.actionTime || p.createdAt || '',
|
||||||
|
id: p.id || '',
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
- map:
|
||||||
|
content: ${{ item.content }}
|
||||||
|
type: ${{ item.type }}
|
||||||
|
likes: ${{ item.likes }}
|
||||||
|
comments: ${{ item.comments }}
|
||||||
|
time: ${{ item.time }}
|
||||||
|
url: https://web.okjike.com/originalPost/${{ item.id }}
|
||||||
|
|
||||||
|
- limit: ${{ args.limit }}
|
||||||
|
|
||||||
|
columns: [content, type, likes, comments, time, url]
|
||||||
Reference in new issue
Block a user