From 078a14861bf36e9efcf3ca613402c67f385dacaa Mon Sep 17 00:00:00 2001 From: yixin-1024 Date: Wed, 24 Jun 2026 16:07:15 +0800 Subject: [PATCH] feat(smzdm): add hot/detail read commands and favorite/zhi write ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the 什么值得买 (smzdm) adapter beyond search, with selectors verified live against the logged-in site and full unit coverage. Reads: - `hot` curated home deals feed (/jingxuan/) — reuses the search feed extractor (identical li.feed-row-wide markup). - `detail` deal detail by id or URL — title, price, optional go.smzdm.com buy link, canonical url. Writes (Strategy.UI): - `favorite` collect a deal via .fav.J_zhi_like_fav; idempotent (detects the already-favorited `active` state). - `zhi` rate a deal 值 (default) / 不值 (--down). Confirms via the up-count bump; smzdm exposes no reliable on-load rated state, so an unconfirmed re-vote is reported honestly rather than as success. Refactor: shared feed extractor, limit/url validation, and FEED_COLUMNS moved to clis/smzdm/shared.js; search.js reuses them. The shared extractor's mall selector now handles the home-feed shape where the mall is an link (not a nested ), so the update-time no longer leaks into `mall`. Existing search behavior and tests are unchanged. Each command carries an `example`; adapter docs updated (docs/adapters/browser/ smzdm.md + index.md). Note: a `checkin` command was deliberately omitted — the smzdm web sign-in endpoint (zhiyou.smzdm.com/user/checkin/jsonp_checkin) is captcha-gated (error_code 110202) and cannot be automated headlessly. Validation: tsc --noEmit clean; npm test green (542 files / 5682 tests); hot/detail/favorite/zhi verified end-to-end via the CLI (incl. -f json). Co-Authored-By: Claude Opus 4.8 (1M context) --- cli-manifest.json | 128 ++++++++++++++++++++++++ clis/smzdm/detail.js | 77 +++++++++++++++ clis/smzdm/detail.test.js | 68 +++++++++++++ clis/smzdm/favorite.js | 61 ++++++++++++ clis/smzdm/favorite.test.js | 42 ++++++++ clis/smzdm/hot.js | 31 ++++++ clis/smzdm/hot.test.js | 67 +++++++++++++ clis/smzdm/search.js | 135 +++---------------------- clis/smzdm/shared.js | 173 +++++++++++++++++++++++++++++++++ clis/smzdm/shared.test.js | 49 ++++++++++ clis/smzdm/zhi.js | 83 ++++++++++++++++ clis/smzdm/zhi.test.js | 45 +++++++++ docs/adapters/browser/smzdm.md | 46 +++++++-- docs/adapters/index.md | 2 +- 14 files changed, 876 insertions(+), 131 deletions(-) create mode 100644 clis/smzdm/detail.js create mode 100644 clis/smzdm/detail.test.js create mode 100644 clis/smzdm/favorite.js create mode 100644 clis/smzdm/favorite.test.js create mode 100644 clis/smzdm/hot.js create mode 100644 clis/smzdm/hot.test.js create mode 100644 clis/smzdm/shared.js create mode 100644 clis/smzdm/shared.test.js create mode 100644 clis/smzdm/zhi.js create mode 100644 clis/smzdm/zhi.test.js diff --git a/cli-manifest.json b/cli-manifest.json index e1f9fa827..64e15ff05 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -31087,11 +31087,104 @@ "navigateBefore": false, "siteSession": "persistent" }, + { + "site": "smzdm", + "name": "detail", + "description": "什么值得买好价详情(标题/价格/购买直达链接)", + "access": "read", + "example": "opencli smzdm detail 177316535 -f json", + "domain": "www.smzdm.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "deal", + "type": "str", + "required": true, + "positional": true, + "help": "Deal id (e.g. 174854494) or full smzdm URL" + } + ], + "columns": [ + "id", + "title", + "price", + "buy_link", + "url" + ], + "type": "js", + "modulePath": "smzdm/detail.js", + "sourceFile": "smzdm/detail.js", + "navigateBefore": "https://www.smzdm.com" + }, + { + "site": "smzdm", + "name": "favorite", + "description": "收藏一条好价(需登录)", + "access": "write", + "example": "opencli smzdm favorite 177316535", + "domain": "www.smzdm.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "deal", + "type": "str", + "required": true, + "positional": true, + "help": "Deal id (e.g. 174854494) or full smzdm URL" + } + ], + "columns": [ + "status", + "message" + ], + "type": "js", + "modulePath": "smzdm/favorite.js", + "sourceFile": "smzdm/favorite.js", + "navigateBefore": true + }, + { + "site": "smzdm", + "name": "hot", + "description": "什么值得买首页精选好价流", + "access": "read", + "example": "opencli smzdm hot --limit 5 -f json", + "domain": "www.smzdm.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "title", + "price", + "mall", + "updated_at", + "zhi_count", + "buzhi_count", + "favorite_count", + "comments", + "url" + ], + "type": "js", + "modulePath": "smzdm/hot.js", + "sourceFile": "smzdm/hot.js", + "navigateBefore": "https://www.smzdm.com" + }, { "site": "smzdm", "name": "search", "description": "什么值得买搜索好价", "access": "read", + "example": "opencli smzdm search \"无线耳机\" --limit 5 -f json", "domain": "www.smzdm.com", "strategy": "cookie", "browser": true, @@ -31128,6 +31221,41 @@ "sourceFile": "smzdm/search.js", "navigateBefore": "https://www.smzdm.com" }, + { + "site": "smzdm", + "name": "zhi", + "description": "给一条好价打分:值(默认)或不值(--down)(需登录)", + "access": "write", + "example": "opencli smzdm zhi 177316535", + "domain": "www.smzdm.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "deal", + "type": "str", + "required": true, + "positional": true, + "help": "Deal id (e.g. 174854494) or full smzdm URL" + }, + { + "name": "down", + "type": "bool", + "default": false, + "required": false, + "help": "Vote 不值 instead of 值" + } + ], + "columns": [ + "status", + "vote", + "message" + ], + "type": "js", + "modulePath": "smzdm/zhi.js", + "sourceFile": "smzdm/zhi.js", + "navigateBefore": true + }, { "site": "spotify", "name": "auth", diff --git a/clis/smzdm/detail.js b/clis/smzdm/detail.js new file mode 100644 index 000000000..0cb750848 --- /dev/null +++ b/clis/smzdm/detail.js @@ -0,0 +1,77 @@ +/** + * 什么值得买好价详情 — browser cookie, DOM scraping. + * + * Resolves a deal id or URL to its detail page and extracts the headline + * fields. Verified live against https://www.smzdm.com/p// : + * - title ←

+ * - price ← .price-large (fallback .price) + * - buy_link ← outbound affiliate redirect (a[href*="go.smzdm.com"]); many + * informational deals have none, so it defaults to '' rather than dropping + * the column. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { resolveDealUrl, unwrapEvaluateResult } from './shared.js'; + +const DETAIL_COLUMNS = ['id', 'title', 'price', 'buy_link', 'url']; + +/** In-page extraction script for a smzdm deal detail page. */ +export function buildSmzdmDetailJs() { + return ` + (() => { + const text = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : ''; + const titleEl = document.querySelector('h1.item-name') || document.querySelector('h1'); + const title = text(titleEl); + const priceEl = document.querySelector('.price-large') || document.querySelector('.price'); + const price = text(priceEl); + // Outbound buy CTA — only present on deals with an affiliate link. + // Restrict to the canonical smzdm redirect host so we never surface a + // foreign URL. + let buy_link = ''; + const buyEl = document.querySelector('a[href*="go.smzdm.com"]'); + if (buyEl) { + try { + const u = new URL(buyEl.href, location.href); + if (u.protocol === 'https:' && u.hostname.toLowerCase() === 'go.smzdm.com') { + buy_link = u.toString(); + } + } catch {} + } + const m = location.pathname.match(/\\/p\\/([a-z0-9]+)\\//i); + return { id: m ? m[1] : '', title, price, buy_link, url: location.href }; + })() + `; +} + +/** Fail closed unless the extractor returned a populated detail object. */ +export function requireDetail(payload) { + const row = unwrapEvaluateResult(payload); + if (!row || typeof row !== 'object' || Array.isArray(row) || typeof row.title !== 'string') { + throw new CommandExecutionError('Unexpected SMZDM detail extraction payload shape.'); + } + if (!row.title) { + throw new CommandExecutionError('Could not extract a title from the deal page; not a deal detail page or not logged in.'); + } + return row; +} + +export const smzdmDetailCommand = cli({ + site: 'smzdm', + name: 'detail', + access: 'read', + description: '什么值得买好价详情(标题/价格/购买直达链接)', + example: 'opencli smzdm detail 177316535 -f json', + domain: 'www.smzdm.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'deal', required: true, positional: true, help: 'Deal id (e.g. 174854494) or full smzdm URL' }, + ], + columns: DETAIL_COLUMNS, + func: async (page, kwargs) => { + const url = resolveDealUrl(kwargs.deal); + await page.goto(url); + return [requireDetail(await page.evaluate(buildSmzdmDetailJs()))]; + }, +}); + +export const __test__ = { buildSmzdmDetailJs, requireDetail, DETAIL_COLUMNS }; diff --git a/clis/smzdm/detail.test.js b/clis/smzdm/detail.test.js new file mode 100644 index 000000000..fbcb91028 --- /dev/null +++ b/clis/smzdm/detail.test.js @@ -0,0 +1,68 @@ +import { JSDOM } from 'jsdom'; +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { smzdmDetailCommand, __test__ } from './detail.js'; + +function runDetail(html, url = 'https://www.smzdm.com/p/177316535/') { + const dom = new JSDOM(html, { url, runScripts: 'outside-only' }); + return dom.window.eval(__test__.buildSmzdmDetailJs()); +} + +describe('smzdm/detail', () => { + it('declares write-free read access and the detail column set', () => { + expect(smzdmDetailCommand.access).toBe('read'); + expect(smzdmDetailCommand.columns).toEqual(['id', 'title', 'price', 'buy_link', 'url']); + }); + + it('extracts id, title and price from a deal detail page', () => { + const html = ` +

香其食品 香其 特级东北老酱油 2.4kg*1桶

+
19.99
`; + const row = runDetail(html); + expect(row).toEqual({ + id: '177316535', + title: '香其食品 香其 特级东北老酱油 2.4kg*1桶', + price: '19.99元', + buy_link: '', + url: 'https://www.smzdm.com/p/177316535/', + }); + }); + + it('captures a go.smzdm.com outbound buy link when present', () => { + const html = ` +

Deal

2943.51元 +
去购买`; + const row = runDetail(html); + expect(row.price).toBe('2943.51元'); + expect(row.buy_link).toBe('https://go.smzdm.com/abc123'); + }); + + it('drops a buy link whose host only looks like go.smzdm.com', () => { + const html = `

Deal

去购买`; + expect(runDetail(html).buy_link).toBe(''); + }); + + it('falls back to .price when .price-large is absent', () => { + const html = `

Deal

9.9元`; + expect(runDetail(html).price).toBe('9.9元'); + }); + + it('resolves alphanumeric post slugs into the id field', () => { + const html = `

Article deal

`; + expect(runDetail(html, 'https://post.smzdm.com/p/aggrg8kw/').id).toBe('aggrg8kw'); + }); + + it('fails closed when the title is missing', () => { + expect(() => __test__.requireDetail({ id: '1', title: '', price: '', buy_link: '', url: 'x' })).toThrow(CommandExecutionError); + }); + + it('fails closed on a non-object extraction payload', () => { + expect(() => __test__.requireDetail(['not', 'an', 'object'])).toThrow(CommandExecutionError); + }); + + it('validates the deal argument before browser navigation', async () => { + const page = { goto: vi.fn(), evaluate: vi.fn(), wait: vi.fn() }; + await expect(smzdmDetailCommand.func(page, { deal: 'https://evil.example/p/1/' })).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); +}); diff --git a/clis/smzdm/favorite.js b/clis/smzdm/favorite.js new file mode 100644 index 000000000..68d52c942 --- /dev/null +++ b/clis/smzdm/favorite.js @@ -0,0 +1,61 @@ +/** + * 什么值得买收藏好价 — write op via the detail page's collect button. + * + * Verified live: the collect control is `div.fav.J_zhi_like_fav` (carries + * `data-article`); clicking it adds the `active` class and bumps the count. + * Idempotent like `twitter like` — re-running on an already-collected deal + * reports success without double-acting. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { resolveDealUrl } from './shared.js'; + +export const smzdmFavoriteCommand = cli({ + site: 'smzdm', + name: 'favorite', + access: 'write', + description: '收藏一条好价(需登录)', + example: 'opencli smzdm favorite 177316535', + domain: 'www.smzdm.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'deal', required: true, positional: true, help: 'Deal id (e.g. 174854494) or full smzdm URL' }, + ], + columns: ['status', 'message'], + func: async (page, kwargs) => { + if (!page) + throw new CommandExecutionError('Browser session required for smzdm favorite'); + const url = resolveDealUrl(kwargs.deal); + await page.goto(url); + await page.wait({ selector: '.fav.J_zhi_like_fav' }); + const result = await page.evaluate(`(async () => { + try { + const btn = document.querySelector('.fav.J_zhi_like_fav'); + if (!btn) { + return { ok: false, message: 'Could not find the favorite button. Not a deal page or not logged in?' }; + } + const isFaved = (el) => el.classList.contains('active') || el.classList.contains('faved') || el.classList.contains('on'); + if (isFaved(btn)) { + return { ok: true, message: 'Deal is already favorited.' }; + } + btn.click(); + // Poll for the active class / count bump to confirm the write landed. + for (let i = 0; i < 16; i++) { + await new Promise((r) => setTimeout(r, 250)); + const now = document.querySelector('.fav.J_zhi_like_fav'); + if (now && isFaved(now)) { + return { ok: true, message: 'Deal favorited.' }; + } + } + return { ok: false, message: 'Favorite click was sent but the UI did not confirm within 4s.' }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); + if (result.ok) { + await page.wait(1); + } + return [{ status: result.ok ? 'success' : 'failed', message: result.message }]; + }, +}); diff --git a/clis/smzdm/favorite.test.js b/clis/smzdm/favorite.test.js new file mode 100644 index 000000000..415cdee01 --- /dev/null +++ b/clis/smzdm/favorite.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { smzdmFavoriteCommand } from './favorite.js'; + +describe('smzdm/favorite', () => { + it('declares write access and a status/message result shape', () => { + expect(smzdmFavoriteCommand.access).toBe('write'); + expect(smzdmFavoriteCommand.columns).toEqual(['status', 'message']); + expect(smzdmFavoriteCommand.strategy).toBe('ui'); + }); + + it('requires a browser session', async () => { + await expect(smzdmFavoriteCommand.func(null, { deal: '174854494' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('rejects an off-domain deal argument before navigation', async () => { + const page = { goto: vi.fn(), evaluate: vi.fn(), wait: vi.fn() }; + await expect(smzdmFavoriteCommand.func(page, { deal: 'https://evil.example/p/1/' })).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('maps a confirmed favorite into a success row', async () => { + const page = { + goto: vi.fn(), + wait: vi.fn(), + evaluate: vi.fn().mockResolvedValue({ ok: true, message: 'Deal favorited.' }), + }; + const rows = await smzdmFavoriteCommand.func(page, { deal: '174854494' }); + expect(rows).toEqual([{ status: 'success', message: 'Deal favorited.' }]); + expect(page.goto).toHaveBeenCalledWith('https://www.smzdm.com/p/174854494/'); + }); + + it('maps a failed favorite into a failed row', async () => { + const page = { + goto: vi.fn(), + wait: vi.fn(), + evaluate: vi.fn().mockResolvedValue({ ok: false, message: 'not logged in' }), + }; + const rows = await smzdmFavoriteCommand.func(page, { deal: '174854494' }); + expect(rows).toEqual([{ status: 'failed', message: 'not logged in' }]); + }); +}); diff --git a/clis/smzdm/hot.js b/clis/smzdm/hot.js new file mode 100644 index 000000000..ca1931625 --- /dev/null +++ b/clis/smzdm/hot.js @@ -0,0 +1,31 @@ +/** + * 什么值得买首页精选好价流 — browser cookie, DOM scraping. + * + * The curated home feed at `/jingxuan/` renders the exact same + * `li.feed-row-wide` markup as search results, so we reuse the shared feed + * extractor and only swap the URL. Verified live against + * https://www.smzdm.com/jingxuan/ (prices + `/p//` deal URLs). + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { buildSmzdmFeedJs, FEED_COLUMNS, parseLimit, requireRows } from './shared.js'; + +export const smzdmHotCommand = cli({ + site: 'smzdm', + name: 'hot', + access: 'read', + description: '什么值得买首页精选好价流', + example: 'opencli smzdm hot --limit 5 -f json', + domain: 'www.smzdm.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, + ], + columns: FEED_COLUMNS, + func: async (page, kwargs) => { + const limit = parseLimit(kwargs.limit); + await page.goto('https://www.smzdm.com/jingxuan/'); + return requireRows(await page.evaluate(buildSmzdmFeedJs(limit))); + }, +}); + +export const __test__ = { buildSmzdmFeedJs, parseLimit, requireRows }; diff --git a/clis/smzdm/hot.test.js b/clis/smzdm/hot.test.js new file mode 100644 index 000000000..98f4079a1 --- /dev/null +++ b/clis/smzdm/hot.test.js @@ -0,0 +1,67 @@ +import { JSDOM } from 'jsdom'; +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError } from '@jackwener/opencli/errors'; +import { smzdmHotCommand, __test__ } from './hot.js'; + +function runFeed(html, limit = 20, url = 'https://www.smzdm.com/jingxuan/') { + const dom = new JSDOM(html, { url, runScripts: 'outside-only' }); + return dom.window.eval(__test__.buildSmzdmFeedJs(limit)); +} + +describe('smzdm/hot', () => { + it('declares read access and the shared feed column set', () => { + expect(smzdmHotCommand.access).toBe('read'); + expect(smzdmHotCommand.columns).toEqual([ + 'rank', 'title', 'price', 'mall', 'updated_at', + 'zhi_count', 'buzhi_count', 'favorite_count', 'comments', 'url', + ]); + }); + + it('extracts the curated home feed with the same markup as search', () => { + const html = ``; + const rows = runFeed(html); + expect(rows).toEqual([ + { + rank: 1, + title: '修洁 山型凸面成人护龈软毛牙刷', + price: '19.9元', + mall: '', + updated_at: '', + zhi_count: 0, + buzhi_count: 0, + favorite_count: 0, + comments: 0, + url: 'https://www.smzdm.com/p/177316528/', + }, + ]); + }); + + it('extracts the mall from an link and keeps the time out of it (home feed shape)', () => { + const html = ``; + const [row] = runFeed(html); + expect(row.mall).toBe('天猫超市'); + expect(row.updated_at).toBe('16:18'); + }); + + it('respects the limit argument', () => { + const li = `
  • Deal
  • `; + expect(runFeed(``, 2)).toHaveLength(2); + }); + + it('validates --limit before browser navigation', async () => { + const page = { goto: vi.fn(), evaluate: vi.fn() }; + await expect(smzdmHotCommand.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError); + await expect(smzdmHotCommand.func(page, { limit: 101 })).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); +}); diff --git a/clis/smzdm/search.js b/clis/smzdm/search.js index 2ca809b83..795a4510d 100644 --- a/clis/smzdm/search.js +++ b/clis/smzdm/search.js @@ -4,146 +4,39 @@ * Fix: The old adapter used `search.smzdm.com/ajax/` which returns 404. * New approach: navigate to `search.smzdm.com/?c=home&s=&v=b` * and scrape the rendered DOM directly. + * + * The DOM extractor and helpers live in `./shared.js` because the curated home + * feed (`hot`) renders identical `li.feed-row-wide` markup and reuses them. */ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; - -function unwrapEvaluateResult(payload) { - if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) { - return payload.data; - } - return payload; -} - -function requireSearchRows(payload) { - const rows = unwrapEvaluateResult(payload); - if (!Array.isArray(rows)) { - throw new CommandExecutionError('Unexpected SMZDM search extraction payload shape; expected an array of rows.'); - } - return rows; -} - -function parseLimit(raw) { - let parsed; - if (raw == null) { - parsed = 20; - } - else if (typeof raw === 'number') { - parsed = raw; - } - else if (typeof raw === 'string' && /^[0-9]+$/.test(raw)) { - parsed = Number(raw); - } - else { - parsed = NaN; - } - if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { - throw new ArgumentError(`--limit must be an integer between 1 and 100, got ${JSON.stringify(raw)}`); - } - if (parsed < 1 || parsed > 100) { - throw new ArgumentError(`--limit must be between 1 and 100, got ${parsed}`); - } - return parsed; -} - -/** - * Build the in-page extraction script. Every result row carries the full - * declared column set; interaction metrics default to 0 and the update time - * to '' when a list item omits them, so no column is ever silently dropped. - */ -function buildSmzdmSearchJs(limit) { - return ` - (() => { - const limit = ${limit}; - const items = document.querySelectorAll('li.feed-row-wide'); - const results = []; - const normalizeCount = (text) => { - const raw = (text || '').replace(/,/g, '').trim(); - const match = raw.match(/(\\d+(?:\\.\\d+)?)\\s*([万kK]?)/); - if (!match) return 0; - const base = Number(match[1]); - if (!Number.isFinite(base)) return 0; - const unit = match[2]; - if (unit === '万') return Math.round(base * 10000); - if (unit === 'k' || unit === 'K') return Math.round(base * 1000); - return Math.round(base); - }; - const intFrom = (el) => { - if (!el) return 0; - return normalizeCount(el.textContent || ''); - }; - const trustedSmzdmUrl = (raw) => { - const text = (raw || '').trim(); - if (!text) return ''; - let url; - try { - url = text.startsWith('/') - ? new URL(text, 'https://www.smzdm.com') - : new URL(text, location.href); - } catch { - return ''; - } - const hostname = url.hostname.toLowerCase(); - if (url.protocol !== 'https:' || (hostname !== 'www.smzdm.com' && hostname !== 'post.smzdm.com')) { - return ''; - } - return url.toString(); - }; - items.forEach((li) => { - if (results.length >= limit) return; - const titleEl = li.querySelector('h5.feed-block-title > a') - || li.querySelector('h5 > a'); - if (!titleEl) return; - const title = (titleEl.getAttribute('title') || titleEl.textContent || '').trim(); - const url = trustedSmzdmUrl(titleEl.getAttribute('href') || titleEl.href || ''); - if (!title || !url) return; - const priceEl = li.querySelector('.z-highlight'); - const price = priceEl ? priceEl.textContent.trim() : ''; - let mall = ''; - const mallEl = li.querySelector('.z-feed-foot-r .feed-block-extras span') - || li.querySelector('.z-feed-foot-r span'); - if (mallEl) mall = mallEl.textContent.trim(); - // Update time lives as the direct text node(s) of .feed-block-extras, - // alongside the nested mall which we exclude here. - let updated_at = ''; - const extrasEl = li.querySelector('.z-feed-foot-r .feed-block-extras'); - if (extrasEl) { - updated_at = Array.from(extrasEl.childNodes) - .filter((node) => node.nodeType === 3) - .map((node) => (node.textContent || '').trim()) - .filter(Boolean) - .join(' '); - } - const zhi_count = intFrom(li.querySelector('.price-btn-up .unvoted-wrap span')); - const buzhi_count = intFrom(li.querySelector('.price-btn-down .unvoted-wrap span')); - const favorite_count = intFrom(li.querySelector('.feed-btn-fav span')); - const comments = intFrom(li.querySelector('.feed-btn-comment')); - results.push({ rank: results.length + 1, title, price, mall, updated_at, zhi_count, buzhi_count, favorite_count, comments, url }); - }); - return results; - })() - `; -} +import { buildSmzdmFeedJs, FEED_COLUMNS, parseLimit, requireRows, unwrapEvaluateResult } from './shared.js'; export const smzdmSearchCommand = cli({ site: 'smzdm', name: 'search', access: 'read', description: '什么值得买搜索好价', + example: 'opencli smzdm search "无线耳机" --limit 5 -f json', domain: 'www.smzdm.com', strategy: Strategy.COOKIE, args: [ { name: 'query', required: true, positional: true, help: 'Search keyword' }, { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, ], - columns: ['rank', 'title', 'price', 'mall', 'updated_at', 'zhi_count', 'buzhi_count', 'favorite_count', 'comments', 'url'], + columns: FEED_COLUMNS, func: async (page, kwargs) => { const q = encodeURIComponent(kwargs.query); const limit = parseLimit(kwargs.limit); // Navigate directly to search results page await page.goto(`https://search.smzdm.com/?c=home&s=${q}&v=b`); - return requireSearchRows(await page.evaluate(buildSmzdmSearchJs(limit))); + return requireRows(await page.evaluate(buildSmzdmFeedJs(limit))); }, }); -export const __test__ = { buildSmzdmSearchJs, parseLimit, requireSearchRows, unwrapEvaluateResult }; +// Names preserved for the existing test suite (search.test.js). +export const __test__ = { + buildSmzdmSearchJs: buildSmzdmFeedJs, + parseLimit, + requireSearchRows: requireRows, + unwrapEvaluateResult, +}; diff --git a/clis/smzdm/shared.js b/clis/smzdm/shared.js new file mode 100644 index 000000000..ea3968a8a --- /dev/null +++ b/clis/smzdm/shared.js @@ -0,0 +1,173 @@ +/** + * Shared helpers for 什么值得买 (smzdm) adapters. + * + * The feed extractor (`buildSmzdmFeedJs`) is reused by every list-style command + * — search, the curated home feed (`hot`/`jingxuan`), etc. — because smzdm + * renders all of them with the same `li.feed-row-wide` markup. Each adapter + * only swaps the URL it navigates to. + */ +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; + +/** + * Browser Bridge sometimes wraps `page.evaluate` results in a + * `{ session, data }` envelope. Unwrap it so callers always see the raw value. + */ +export function unwrapEvaluateResult(payload) { + if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) { + return payload.data; + } + return payload; +} + +/** Fail closed when an extraction payload is not the array of rows we expect. */ +export function requireRows(payload) { + const rows = unwrapEvaluateResult(payload); + if (!Array.isArray(rows)) { + throw new CommandExecutionError('Unexpected SMZDM extraction payload shape; expected an array of rows.'); + } + return rows; +} + +/** Validate a `--limit` argument up front, before any browser navigation. */ +export function parseLimit(raw, { min = 1, max = 100, fallback = 20 } = {}) { + let parsed; + if (raw == null) { + parsed = fallback; + } + else if (typeof raw === 'number') { + parsed = raw; + } + else if (typeof raw === 'string' && /^[0-9]+$/.test(raw)) { + parsed = Number(raw); + } + else { + parsed = NaN; + } + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new ArgumentError(`--limit must be an integer between ${min} and ${max}, got ${JSON.stringify(raw)}`); + } + if (parsed < min || parsed > max) { + throw new ArgumentError(`--limit must be between ${min} and ${max}, got ${parsed}`); + } + return parsed; +} + +/** + * Build the in-page extraction script for a `li.feed-row-wide` list. Every row + * carries the full declared column set; interaction metrics default to 0 and + * the update time to '' when a list item omits them, so no column is ever + * silently dropped. Untrusted (non-smzdm / non-https) URLs are filtered out. + */ +export function buildSmzdmFeedJs(limit) { + return ` + (() => { + const limit = ${limit}; + const items = document.querySelectorAll('li.feed-row-wide'); + const results = []; + const normalizeCount = (text) => { + const raw = (text || '').replace(/,/g, '').trim(); + const match = raw.match(/(\\d+(?:\\.\\d+)?)\\s*([万kK]?)/); + if (!match) return 0; + const base = Number(match[1]); + if (!Number.isFinite(base)) return 0; + const unit = match[2]; + if (unit === '万') return Math.round(base * 10000); + if (unit === 'k' || unit === 'K') return Math.round(base * 1000); + return Math.round(base); + }; + const intFrom = (el) => { + if (!el) return 0; + return normalizeCount(el.textContent || ''); + }; + const trustedSmzdmUrl = (raw) => { + const text = (raw || '').trim(); + if (!text) return ''; + let url; + try { + url = text.startsWith('/') + ? new URL(text, 'https://www.smzdm.com') + : new URL(text, location.href); + } catch { + return ''; + } + const hostname = url.hostname.toLowerCase(); + if (url.protocol !== 'https:' || (hostname !== 'www.smzdm.com' && hostname !== 'post.smzdm.com')) { + return ''; + } + return url.toString(); + }; + items.forEach((li) => { + if (results.length >= limit) return; + const titleEl = li.querySelector('h5.feed-block-title > a') + || li.querySelector('h5 > a'); + if (!titleEl) return; + const title = (titleEl.getAttribute('title') || titleEl.textContent || '').trim(); + const url = trustedSmzdmUrl(titleEl.getAttribute('href') || titleEl.href || ''); + if (!title || !url) return; + const priceEl = li.querySelector('.z-highlight'); + const price = priceEl ? priceEl.textContent.trim() : ''; + let mall = ''; + const extrasForMall = li.querySelector('.z-feed-foot-r .feed-block-extras'); + if (extrasForMall) { + // The mall renders as a nested (links to /mall/...) on the home + // feed or a nested on search results — never the wrapper + // itself, which also holds the update-time text node. + const mallEl = extrasForMall.querySelector('a, span'); + if (mallEl) mall = mallEl.textContent.trim(); + } + if (!mall) { + const fallbackMall = li.querySelector('.z-feed-foot-r span:not(.feed-block-extras)'); + if (fallbackMall) mall = fallbackMall.textContent.trim(); + } + let updated_at = ''; + const extrasEl = li.querySelector('.z-feed-foot-r .feed-block-extras'); + if (extrasEl) { + updated_at = Array.from(extrasEl.childNodes) + .filter((node) => node.nodeType === 3) + .map((node) => (node.textContent || '').trim()) + .filter(Boolean) + .join(' '); + } + const zhi_count = intFrom(li.querySelector('.price-btn-up .unvoted-wrap span')); + const buzhi_count = intFrom(li.querySelector('.price-btn-down .unvoted-wrap span')); + const favorite_count = intFrom(li.querySelector('.feed-btn-fav span')); + const comments = intFrom(li.querySelector('.feed-btn-comment')); + results.push({ rank: results.length + 1, title, price, mall, updated_at, zhi_count, buzhi_count, favorite_count, comments, url }); + }); + return results; + })() + `; +} + +/** Columns produced by `buildSmzdmFeedJs`, shared by every feed-style command. */ +export const FEED_COLUMNS = ['rank', 'title', 'price', 'mall', 'updated_at', 'zhi_count', 'buzhi_count', 'favorite_count', 'comments', 'url']; + +/** + * Resolve a deal id or full smzdm URL into a canonical detail URL. + * Accepts `174854494`, `/p/174854494/`, or any `https://www.smzdm.com/p/.../` + * (or `post.smzdm.com`) URL. Rejects anything off-domain. + */ +export function resolveDealUrl(raw) { + const text = (raw == null ? '' : String(raw)).trim(); + if (!text) { + throw new ArgumentError('A deal id or smzdm URL is required.'); + } + // Bare numeric id → /p// + if (/^\d+$/.test(text)) { + return `https://www.smzdm.com/p/${text}/`; + } + let url; + try { + url = text.startsWith('/') + ? new URL(text, 'https://www.smzdm.com') + : new URL(text); + } + catch { + throw new ArgumentError(`Not a valid deal id or smzdm URL: ${JSON.stringify(raw)}`); + } + const hostname = url.hostname.toLowerCase(); + if (url.protocol !== 'https:' || (hostname !== 'www.smzdm.com' && hostname !== 'post.smzdm.com')) { + throw new ArgumentError(`Refusing off-domain or non-https URL: ${JSON.stringify(raw)}`); + } + return url.toString(); +} diff --git a/clis/smzdm/shared.test.js b/clis/smzdm/shared.test.js new file mode 100644 index 000000000..eee025499 --- /dev/null +++ b/clis/smzdm/shared.test.js @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { ArgumentError } from '@jackwener/opencli/errors'; +import { parseLimit, resolveDealUrl } from './shared.js'; + +describe('smzdm/shared resolveDealUrl', () => { + it('expands a bare numeric id to a canonical detail URL', () => { + expect(resolveDealUrl('174854494')).toBe('https://www.smzdm.com/p/174854494/'); + }); + + it('resolves a relative /p/ path against www.smzdm.com', () => { + expect(resolveDealUrl('/p/123/')).toBe('https://www.smzdm.com/p/123/'); + }); + + it('accepts an alphanumeric-slug post URL on an allowed host', () => { + expect(resolveDealUrl('https://post.smzdm.com/p/aggrg8kw/')).toBe('https://post.smzdm.com/p/aggrg8kw/'); + }); + + it('rejects off-domain URLs', () => { + expect(() => resolveDealUrl('https://evil.example/p/1/')).toThrow(ArgumentError); + }); + + it('rejects non-https URLs', () => { + expect(() => resolveDealUrl('http://www.smzdm.com/p/1/')).toThrow(ArgumentError); + }); + + it('rejects empty input', () => { + expect(() => resolveDealUrl('')).toThrow(ArgumentError); + expect(() => resolveDealUrl(null)).toThrow(ArgumentError); + }); +}); + +describe('smzdm/shared parseLimit', () => { + it('defaults to 20 when unset', () => { + expect(parseLimit(undefined)).toBe(20); + }); + + it('accepts numeric strings within range', () => { + expect(parseLimit('50')).toBe(50); + expect(parseLimit(1)).toBe(1); + expect(parseLimit(100)).toBe(100); + }); + + it('rejects out-of-range and non-integer input', () => { + expect(() => parseLimit(0)).toThrow(ArgumentError); + expect(() => parseLimit(101)).toThrow(ArgumentError); + expect(() => parseLimit('1e2')).toThrow(ArgumentError); + expect(() => parseLimit('abc')).toThrow(ArgumentError); + }); +}); diff --git a/clis/smzdm/zhi.js b/clis/smzdm/zhi.js new file mode 100644 index 000000000..b22337ad8 --- /dev/null +++ b/clis/smzdm/zhi.js @@ -0,0 +1,83 @@ +/** + * 什么值得买好价打分(值 / 不值)— write op via the detail page rating widget. + * + * Verified live: the rating box is `.score_rateBox.J_score_rating` (id + * `rating_`); the 值 control is `.details_zhi` (#details-zhi) and 不值 + * is `.details_buzhi`. Clicking 值 bumps the up count (verified 0→1 on a fresh + * deal). smzdm does NOT expose a reliable per-account "已打分" state in the + * static DOM — the `.scoredInfo` overlay stays hidden even after rating — so the + * only trustworthy confirmation is the up-count incrementing. We therefore + * report success only when the count moves; a stale count is surfaced as an + * honest "couldn't confirm (likely already rated)" rather than a false success. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { resolveDealUrl } from './shared.js'; + +export const smzdmZhiCommand = cli({ + site: 'smzdm', + name: 'zhi', + access: 'write', + description: '给一条好价打分:值(默认)或不值(--down)(需登录)', + example: 'opencli smzdm zhi 177316535', + domain: 'www.smzdm.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'deal', required: true, positional: true, help: 'Deal id (e.g. 174854494) or full smzdm URL' }, + { name: 'down', type: 'bool', default: false, help: 'Vote 不值 instead of 值' }, + ], + columns: ['status', 'vote', 'message'], + func: async (page, kwargs) => { + if (!page) + throw new CommandExecutionError('Browser session required for smzdm zhi'); + const url = resolveDealUrl(kwargs.deal); + const down = kwargs.down === true || kwargs.down === 'true'; + const vote = down ? '不值' : '值'; + await page.goto(url); + await page.wait({ selector: '.score_rateBox, .J_score_rating, .details_zhi' }); + const result = await page.evaluate(`(async () => { + try { + const down = ${down}; + const box = document.querySelector('.J_score_rating') || document.querySelector('.score_rateBox') || document; + // Already rated → visible "已打分" overlay. + const scored = box.querySelector('.scoredInfo'); + if (scored && scored.offsetParent !== null) { + return { ok: true, message: 'Deal is already rated by this account.' }; + } + const btn = down + ? (box.querySelector('.details_buzhi') || document.querySelector('#details-buzhi')) + : (box.querySelector('.details_zhi') || document.querySelector('#details-zhi')); + if (!btn) { + return { ok: false, message: 'Could not find the rating button. Not a deal page or not logged in?' }; + } + const countEl = () => box.querySelector(down ? '.grey' : '.red'); + const readCount = () => { + const el = countEl(); + const n = el ? parseInt((el.textContent || '').replace(/[^0-9]/g, ''), 10) : NaN; + return Number.isFinite(n) ? n : null; + }; + const before = readCount(); + btn.click(); + for (let i = 0; i < 16; i++) { + await new Promise((r) => setTimeout(r, 250)); + const after = readCount(); + const nowScored = box.querySelector('.scoredInfo'); + if ((before != null && after != null && after > before) || (nowScored && nowScored.offsetParent !== null)) { + return { ok: true, message: 'Rated ' + (down ? '不值' : '值') + '.' }; + } + } + // smzdm surfaces no reliable per-account "已打分" state on load, so a + // missing count bump usually means the deal was already rated by this + // account (a harmless server-side no-op) rather than a hard failure. + return { ok: false, message: 'Vote sent but the up-count did not change within 4s — the deal may already be rated by this account (smzdm exposes no reliable on-load rated state).' }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); + if (result.ok) { + await page.wait(1); + } + return [{ status: result.ok ? 'success' : 'failed', vote, message: result.message }]; + }, +}); diff --git a/clis/smzdm/zhi.test.js b/clis/smzdm/zhi.test.js new file mode 100644 index 000000000..252f9a0de --- /dev/null +++ b/clis/smzdm/zhi.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { smzdmZhiCommand } from './zhi.js'; + +describe('smzdm/zhi', () => { + it('declares write access and a status/vote/message result shape', () => { + expect(smzdmZhiCommand.access).toBe('write'); + expect(smzdmZhiCommand.columns).toEqual(['status', 'vote', 'message']); + expect(smzdmZhiCommand.strategy).toBe('ui'); + }); + + it('requires a browser session', async () => { + await expect(smzdmZhiCommand.func(null, { deal: '174854494' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('rejects an off-domain deal argument before navigation', async () => { + const page = { goto: vi.fn(), evaluate: vi.fn(), wait: vi.fn() }; + await expect(smzdmZhiCommand.func(page, { deal: 'https://evil.example/p/1/' })).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('labels the vote 值 by default and 不值 with --down', async () => { + const page = { + goto: vi.fn(), + wait: vi.fn(), + evaluate: vi.fn().mockResolvedValue({ ok: true, message: 'Rated 值.' }), + }; + const up = await smzdmZhiCommand.func(page, { deal: '174854494' }); + expect(up).toEqual([{ status: 'success', vote: '值', message: 'Rated 值.' }]); + + page.evaluate.mockResolvedValue({ ok: true, message: 'Rated 不值.' }); + const down = await smzdmZhiCommand.func(page, { deal: '174854494', down: true }); + expect(down[0].vote).toBe('不值'); + }); + + it('maps an already-rated deal into a success row', async () => { + const page = { + goto: vi.fn(), + wait: vi.fn(), + evaluate: vi.fn().mockResolvedValue({ ok: true, message: 'Deal is already rated by this account.' }), + }; + const rows = await smzdmZhiCommand.func(page, { deal: '174854494' }); + expect(rows[0].status).toBe('success'); + }); +}); diff --git a/docs/adapters/browser/smzdm.md b/docs/adapters/browser/smzdm.md index 32e7b170f..cf6684582 100644 --- a/docs/adapters/browser/smzdm.md +++ b/docs/adapters/browser/smzdm.md @@ -4,24 +4,52 @@ ## Commands -| Command | Description | -|---------|-------------| -| `opencli smzdm search` | | +| Command | Access | Description | +|---------|--------|-------------| +| `opencli smzdm search ` | read | 搜索好价 — search deals by keyword | +| `opencli smzdm hot` | read | 首页精选好价流 — curated home deals feed (`/jingxuan/`) | +| `opencli smzdm detail ` | read | 好价详情 — title, price, buy link by deal id or URL | +| `opencli smzdm favorite ` | write | 收藏好价 — favorite a deal (idempotent) | +| `opencli smzdm zhi [--down]` | write | 好价打分 — rate a deal 值 (default) / 不值 (`--down`) | ## Usage Examples ```bash -# Quick start -opencli smzdm search --limit 5 +# Search deals +opencli smzdm search "无线耳机" --limit 5 -f json -# JSON output -opencli smzdm search -f json +# Curated home deals feed +opencli smzdm hot --limit 5 -f json -# Verbose mode -opencli smzdm search -v +# Deal detail by id (or full smzdm URL) +opencli smzdm detail 177316535 -f json + +# Favorite a deal (write — requires login) +opencli smzdm favorite 177316535 + +# Rate a deal 值 / 不值 (write — requires login) +opencli smzdm zhi 177316535 +opencli smzdm zhi 177316535 --down + +# Verbose mode for debugging +opencli smzdm search "无线耳机" -v ``` +## Notes + +- `hot` reuses the same `li.feed-row-wide` extractor as `search` — the home + feed and search results share identical markup. +- `detail` accepts a bare deal id (`177316535`), a relative `/p//` path, or a + full `www.smzdm.com` / `post.smzdm.com` URL. `buy_link` is only populated for + deals that carry an outbound `go.smzdm.com` affiliate link. +- `zhi` confirms via the up-count incrementing. smzdm does not expose a reliable + per-account "已打分" state on load, so a re-vote that cannot be confirmed is + reported honestly rather than as a false success. +- There is no `checkin` command: the smzdm web sign-in endpoint is captcha-gated + and cannot be automated headlessly. + ## Prerequisites - Chrome running and **logged into** smzdm.com - [Browser Bridge extension](/guide/browser-bridge) installed +- The `write` commands (`favorite`, `zhi`) require an authenticated session. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 9e1c36260..c1a13dcd1 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -33,7 +33,7 @@ Run `opencli list` for the live registry. | **[ctrip](./browser/ctrip.md)** | `search` `hotel-suggest` | 🌐 Public | | **[booking](./browser/booking.md)** | `search` | 🌐 Public | | **[reuters](./browser/reuters.md)** | `search` `article-detail` | 🔐 Browser | -| **[smzdm](./browser/smzdm.md)** | `search` | 🔐 Browser | +| **[smzdm](./browser/smzdm.md)** | `search` `hot` `detail` `favorite` `zhi` | 🔐 Browser | | **[jike](./browser/jike.md)** | `feed` `search` `post` `topic` `user` `create` `comment` `like` `repost` `notifications` | 🔐 Browser | | **[ke](./browser/ke.md)** | `ershoufang` `zufang` `xiaoqu` `chengjiao` | 🔐 Browser | | **[jimeng](./browser/jimeng.md)** | `generate` `history` | 🔐 Browser |