diff --git a/src/clis/jike/comment.ts b/src/clis/jike/comment.ts new file mode 100644 index 0000000..290bf76 --- /dev/null +++ b/src/clis/jike/comment.ts @@ -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, + }]; + }, +}); diff --git a/src/clis/jike/create.ts b/src/clis/jike/create.ts new file mode 100644 index 0000000..0731496 --- /dev/null +++ b/src/clis/jike/create.ts @@ -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, + }]; + }, +}); diff --git a/src/clis/jike/feed.ts b/src/clis/jike/feed.ts new file mode 100644 index 0000000..79fd185 --- /dev/null +++ b/src/clis/jike/feed.ts @@ -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 => { + 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); + }, +}); diff --git a/src/clis/jike/like.ts b/src/clis/jike/like.ts new file mode 100644 index 0000000..6193943 --- /dev/null +++ b/src/clis/jike/like.ts @@ -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, + }]; + }, +}); diff --git a/src/clis/jike/notifications.ts b/src/clis/jike/notifications.ts new file mode 100644 index 0000000..6d6f4cf --- /dev/null +++ b/src/clis/jike/notifications.ts @@ -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); + }, +}); diff --git a/src/clis/jike/post.yaml b/src/clis/jike/post.yaml new file mode 100644 index 0000000..450ed75 --- /dev/null +++ b/src/clis/jike/post.yaml @@ -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] diff --git a/src/clis/jike/repost.ts b/src/clis/jike/repost.ts new file mode 100644 index 0000000..0b9f4f6 --- /dev/null +++ b/src/clis/jike/repost.ts @@ -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, + }]; + }, +}); diff --git a/src/clis/jike/search.ts b/src/clis/jike/search.ts new file mode 100644 index 0000000..e0eb9d6 --- /dev/null +++ b/src/clis/jike/search.ts @@ -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 => { + 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); + }, +}); diff --git a/src/clis/jike/shared.ts b/src/clis/jike/shared.ts new file mode 100644 index 0000000..e694723 --- /dev/null +++ b/src/clis/jike/shared.ts @@ -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(); diff --git a/src/clis/jike/topic.yaml b/src/clis/jike/topic.yaml new file mode 100644 index 0000000..be59790 --- /dev/null +++ b/src/clis/jike/topic.yaml @@ -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] diff --git a/src/clis/jike/user.yaml b/src/clis/jike/user.yaml new file mode 100644 index 0000000..63f2e5b --- /dev/null +++ b/src/clis/jike/user.yaml @@ -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]