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:
AstroHan authored and GitHub committed 2026-03-20 14:08:12 +08:00
1 parent 44f0bbe94d
commit d6e0aa120b
11 files changed
+935

No files matched your search

+113
View File
@@ -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,
}];
},
});
+113
View File
@@ -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,
}];
},
});
+74
View File
@@ -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);
},
});
+65
View File
@@ -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,
}];
},
});
+185
View File
@@ -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);
},
});
+58
View File
@@ -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]
+114
View File
@@ -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,
}];
},
});
+74
View File
@@ -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);
},
});
+36
View File
@@ -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();
+52
View File
@@ -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]
+51
View File
@@ -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]