Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions clis/smzdm/detail.js
Original file line number Diff line number Diff line change
@@ -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/<id>/ :
* - title ← <h1>
* - 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 };
68 changes: 68 additions & 0 deletions clis/smzdm/detail.test.js
Original file line number Diff line number Diff line change
@@ -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 = `
<h1 class="item-name">香其食品 香其 特级东北老酱油 2.4kg*1桶</h1>
<div class="over-container"><span class="price-large"><span class="num">19.99</span><span class="yuan">元</span></span></div>`;
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 = `
<h1>Deal</h1><span class="price-large">2943.51元</span>
<a href="https://go.smzdm.com/abc123">去购买</a>`;
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 = `<h1>Deal</h1><a href="https://go.smzdm.com.evil.example/x">去购买</a>`;
expect(runDetail(html).buy_link).toBe('');
});

it('falls back to .price when .price-large is absent', () => {
const html = `<h1>Deal</h1><span class="price">9.9元</span>`;
expect(runDetail(html).price).toBe('9.9元');
});

it('resolves alphanumeric post slugs into the id field', () => {
const html = `<h1>Article deal</h1>`;
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();
});
});
61 changes: 61 additions & 0 deletions clis/smzdm/favorite.js
Original file line number Diff line number Diff line change
@@ -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 }];
},
});
42 changes: 42 additions & 0 deletions clis/smzdm/favorite.test.js
Original file line number Diff line number Diff line change
@@ -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' }]);
});
});
Loading
Loading