From eb9050fadd4608f7d7db27442930a705abacb81d Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 15 Mar 2026 20:59:39 +0800 Subject: [PATCH] feat(v2ex): add daily, me, and notifications session adapters --- src/clis/v2ex/daily.ts | 105 +++++++++++++++++++++++++++++++++ src/clis/v2ex/me.ts | 103 ++++++++++++++++++++++++++++++++ src/clis/v2ex/notifications.ts | 77 ++++++++++++++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 src/clis/v2ex/daily.ts create mode 100644 src/clis/v2ex/me.ts create mode 100644 src/clis/v2ex/notifications.ts diff --git a/src/clis/v2ex/daily.ts b/src/clis/v2ex/daily.ts new file mode 100644 index 0000000..5376adc --- /dev/null +++ b/src/clis/v2ex/daily.ts @@ -0,0 +1,105 @@ +/** + * V2EX Daily Check-in adapter. + */ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'v2ex', + name: 'daily', + description: 'V2EX 每日签到并领取铜币', + domain: 'www.v2ex.com', + strategy: Strategy.COOKIE, + browser: true, + forceExtension: true, + args: [], + columns: ['status', 'message'], + func: async (page: IPage | null) => { + if (!page) throw new Error('Browser page required'); + + if (process.env.OPENCLI_VERBOSE) { + console.error('[opencli:v2ex] Navigating to /mission/daily'); + } + await page.goto('https://www.v2ex.com/mission/daily'); + + // Cloudflare challenge bypass wait + for (let i = 0; i < 5; i++) { + await new Promise(r => setTimeout(r, 1500)); + const title = await page.evaluate(`() => document.title`); + if (!title?.includes('Just a moment')) break; + if (process.env.OPENCLI_VERBOSE) console.error('[opencli:v2ex] Waiting for Cloudflare...'); + } + + // Evaluate DOM to find if we need to check in + const checkResult = await page.evaluate(` + async () => { + const btn = document.querySelector('input.super.normal.button'); + if (!btn || !btn.value.includes('领取')) { + return { claimed: true, message: '今日奖励已发/无需领取' }; + } + + const onclick = btn.getAttribute('onclick'); + if (onclick) { + const match = onclick.match(/once=(\\d+)/); + if (match) { + return { claimed: false, once: match[1], message: btn.value }; + } + } + + return { + claimed: false, + error: '找到了按钮,但未能提取 once token', + debug_title: document.title, + debug_body: document.body.innerText.substring(0, 200).replace(/\\n/g, ' ') + }; + } + `); + + if (checkResult.error) { + if (process.env.OPENCLI_VERBOSE) { + console.error(`[opencli:v2ex:debug] Page Title: ${checkResult.debug_title}`); + console.error(`[opencli:v2ex:debug] Page Body: ${checkResult.debug_body}`); + } + throw new Error(checkResult.error); + } + + if (checkResult.claimed) { + return [{ status: '✅ 已签到', message: checkResult.message }]; + } + + // Perform check in + if (process.env.OPENCLI_VERBOSE) { + console.error(`[opencli:v2ex] Found check-in token: once=${checkResult.once}. Checking in...`); + } + + await page.goto(`https://www.v2ex.com/mission/daily/redeem?once=${checkResult.once}`); + await new Promise(resolve => setTimeout(resolve, 3000)); // wait longer for redirect + + // Verify result + const verifyResult = await page.evaluate(` + async () => { + const btn = document.querySelector('input.super.normal.button'); + if (!btn || !btn.value.includes('领取')) { + // fetch balance to show user + let balance = ''; + const balanceLink = document.querySelector('a.balance_area'); + if (balanceLink) { + balance = Array.from(balanceLink.childNodes) + .filter(n => n.nodeType === 3) + .map(n => n.textContent?.trim()) + .join(' ') + .trim(); + } + return { success: true, balance }; + } + return { success: false }; + } + `); + + if (verifyResult.success) { + return [{ status: '🎉 签到成功', message: `当前余额: ${verifyResult.balance || '未知'}` }]; + } else { + return [{ status: '❌ 签到失败', message: '未能确认签到结果,请手动检查' }]; + } + }, +}); diff --git a/src/clis/v2ex/me.ts b/src/clis/v2ex/me.ts new file mode 100644 index 0000000..0e24bb8 --- /dev/null +++ b/src/clis/v2ex/me.ts @@ -0,0 +1,103 @@ +/** + * V2EX Me (Profile/Balance) adapter. + */ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'v2ex', + name: 'me', + description: 'V2EX 获取个人资料 (余额/未读提醒)', + domain: 'www.v2ex.com', + strategy: Strategy.COOKIE, + browser: true, + forceExtension: true, + args: [], + columns: ['username', 'balance', 'unread_notifications', 'daily_reward_ready'], + func: async (page: IPage | null) => { + if (!page) throw new Error('Browser page required'); + + if (process.env.OPENCLI_VERBOSE) { + console.error('[opencli:v2ex] Navigating to /'); + } + await page.goto('https://www.v2ex.com/'); + + // Cloudflare challenge bypass wait + for (let i = 0; i < 5; i++) { + await new Promise(r => setTimeout(r, 1500)); + const title = await page.evaluate(`() => document.title`); + if (!title?.includes('Just a moment')) break; + if (process.env.OPENCLI_VERBOSE) console.error('[opencli:v2ex] Waiting for Cloudflare...'); + } + + // Evaluate DOM to extract user profile + const data = await page.evaluate(` + async () => { + let username = 'Unknown'; + const navLinks = Array.from(document.querySelectorAll('a.top')).map(a => a.textContent?.trim()); + if (navLinks.length > 1 && navLinks[0] === '首页') { + username = navLinks[1] || 'Unknown'; + } + + if (username === 'Unknown') { + // Fallback check just in case + const profileEl = document.querySelector('a[href^="/member/"]'); + if (profileEl && profileEl.textContent && profileEl.textContent.trim().length > 0) { + username = profileEl.textContent.trim(); + } + } + + let balance = '0'; + const balanceLink = document.querySelector('a.balance_area'); + if (balanceLink) { + balance = Array.from(balanceLink.childNodes) + .filter(n => n.nodeType === 3) + .map(n => n.textContent?.trim()) + .join(' ') + .trim(); + } + + let unread_notifications = '0'; + const notesEl = document.querySelector('a[href="/notifications"]'); + if (notesEl) { + const text = notesEl.textContent?.trim() || ''; + const match = text.match(/(\\d+)\\s*未读提醒/); + if (match) { + unread_notifications = match[1]; + } + } + + let daily_reward_ready = false; + const dailyEl = document.querySelector('a[href^="/mission/daily"]'); + if (dailyEl && dailyEl.textContent?.includes('领取今日的登录奖励')) { + daily_reward_ready = true; + } + + if (username === 'Unknown') { + return { + error: '请先登录 V2EX(可能是 Cookie 未配置或已失效)', + debug_title: document.title, + debug_body: document.body.innerText.substring(0, 200).replace(/\\n/g, ' ') + }; + } + + return { + username, + balance, + unread_notifications, + daily_reward_ready: daily_reward_ready ? '是' : '否' + }; + } + `); + + if (data.error) { + if (process.env.OPENCLI_VERBOSE) { + console.error(`[opencli:v2ex:debug] Page Title: ${data.debug_title}`); + console.error(`[opencli:v2ex:debug] Page Body: ${data.debug_body}`); + } + throw new Error(data.error); + } + + return [data]; + }, +}); diff --git a/src/clis/v2ex/notifications.ts b/src/clis/v2ex/notifications.ts new file mode 100644 index 0000000..e28575f --- /dev/null +++ b/src/clis/v2ex/notifications.ts @@ -0,0 +1,77 @@ +/** + * V2EX Notifications adapter. + */ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'v2ex', + name: 'notifications', + description: 'V2EX 获取提醒 (回复/由于)', + domain: 'www.v2ex.com', + strategy: Strategy.COOKIE, + browser: true, + forceExtension: true, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of notifications' } + ], + columns: ['type', 'content', 'time'], + func: async (page: IPage | null, kwargs) => { + if (!page) throw new Error('Browser page required'); + + if (process.env.OPENCLI_VERBOSE) { + console.error('[opencli:v2ex] Navigating to /notifications'); + } + await page.goto('https://www.v2ex.com/notifications'); + await new Promise(r => setTimeout(r, 1500)); // waitForLoadState doesn't always work robustly + + // Evaluate DOM to extract notifications + const data = await page.evaluate(` + async () => { + const items = Array.from(document.querySelectorAll('#Main .box .cell[id^="n_"]')); + return items.map(item => { + let type = '通知'; + let time = ''; + + // determine type based on text content + const text = item.textContent || ''; + if (text.includes('回复了你')) type = '回复'; + else if (text.includes('感谢了你')) type = '感谢'; + else if (text.includes('收藏了你')) type = '收藏'; + else if (text.includes('提及你')) type = '提及'; + + const timeEl = item.querySelector('.snow'); + if (timeEl) { + time = timeEl.textContent?.trim() || ''; + } + + // payload contains the actual reply text if any + let payload = ''; + const payloadEl = item.querySelector('.payload'); + if (payloadEl) { + payload = payloadEl.textContent?.trim() || ''; + } + + // fallback to full text cleaning if no payload (e.g. for favorites/thanks) + let content = payload; + if (!content) { + content = text.replace(/\\s+/g, ' ').trim(); + // strip out time from content if present + if (time && content.includes(time)) { + content = content.replace(time, '').trim(); + } + } + + return { type, content, time }; + }); + } + `); + + if (!Array.isArray(data)) { + throw new Error('Failed to parse notifications data'); + } + + const limit = kwargs.limit || 20; + return data.slice(0, limit); + }, +});