From 3c3af72969cef8d42800655a15193a4de8fdf284 Mon Sep 17 00:00:00 2001 From: Ao Liu Date: Sat, 1 Aug 2026 15:07:58 +0800 Subject: [PATCH] fix(nowcoder): preserve interaction metric availability --- cli-manifest.json | 7 +++ clis/nowcoder/detail.js | 18 +++++-- clis/nowcoder/metrics.js | 45 ++++++++++++++++ clis/nowcoder/metrics.test.js | 87 +++++++++++++++++++++++++++++++ docs/adapters/browser/nowcoder.md | 12 +++++ 5 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 clis/nowcoder/metrics.js create mode 100644 clis/nowcoder/metrics.test.js diff --git a/cli-manifest.json b/cli-manifest.json index 41a3d1c95..7c485ce8f 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -26362,8 +26362,15 @@ "school", "content", "likes", + "likes_status", + "collects", + "collects_status", "comments", + "comments_status", + "shares", + "shares_status", "views", + "views_status", "time", "location" ], diff --git a/clis/nowcoder/detail.js b/clis/nowcoder/detail.js index 247838fa6..5a6238c24 100644 --- a/clis/nowcoder/detail.js +++ b/clis/nowcoder/detail.js @@ -1,4 +1,5 @@ import { cli } from '@jackwener/opencli/registry'; +import { projectNowcoderMetrics } from './metrics.js'; cli({ site: 'nowcoder', @@ -9,13 +10,22 @@ cli({ args: [ { name: 'id', positional: true, required: true, help: 'Post ID, UUID, or URL' }, ], - columns: ['title', 'author', 'school', 'content', 'likes', 'comments', 'views', 'time', 'location'], + columns: [ + 'title', 'author', 'school', 'content', + 'likes', 'likes_status', + 'collects', 'collects_status', + 'comments', 'comments_status', + 'shares', 'shares_status', + 'views', 'views_status', + 'time', 'location', + ], pipeline: [ { navigate: 'https://www.nowcoder.com' }, { evaluate: `(async () => { const raw = \${{ args.id | json }}; const base = 'https://gw-c.nowcoder.com'; const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim(); + const projectMetrics = ${projectNowcoderMetrics.toString()}; let id = raw; const urlMatch = raw.match(/discuss\\/(\\d+)/); @@ -44,15 +54,13 @@ cli({ if (!data) throw new Error('Post not found: ' + id); const user = data.userBrief || {}; - const freq = data.frequencyData || {}; + const metrics = projectMetrics(data.frequencyData); return [{ title: data.title || '(untitled)', author: user.nickname || '', school: user.educationInfo || '', content: strip(data.content || '').substring(0, 500), - likes: freq.likeCnt || 0, - comments: freq.commentCnt || freq.totalCommentCnt || 0, - views: freq.viewCnt || 0, + ...metrics, time: data.createdAt ? new Date(data.createdAt).toISOString().slice(0, 19) : '', location: data.ip4Location || '', }]; diff --git a/clis/nowcoder/metrics.js b/clis/nowcoder/metrics.js new file mode 100644 index 000000000..dbad60aff --- /dev/null +++ b/clis/nowcoder/metrics.js @@ -0,0 +1,45 @@ +/** + * Project Nowcoder frequencyData without conflating a real zero with a + * missing or malformed field. This function is serialized into the browser + * evaluation context, so it must remain self-contained. + */ +export function projectNowcoderMetrics(frequencyData) { + const source = frequencyData && typeof frequencyData === 'object' + ? frequencyData + : {}; + const readMetric = (...keys) => { + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(source, key)) + continue; + const raw = source[key]; + if (raw == null || raw === '') + continue; + const value = typeof raw === 'number' ? raw : Number(String(raw).trim()); + if (Number.isInteger(value) && value >= 0) { + return { value, status: 'available' }; + } + } + return { value: null, status: 'unavailable' }; + }; + + const likes = readMetric('likeCnt'); + // Nowcoder calls this followCnt in the API, while the detail UI labels the + // same interaction 收藏 (collect/save). + const collects = readMetric('followCnt'); + const comments = readMetric('commentCnt', 'totalCommentCnt'); + const shares = readMetric('shareCnt'); + const views = readMetric('viewCnt'); + + return { + likes: likes.value, + likes_status: likes.status, + collects: collects.value, + collects_status: collects.status, + comments: comments.value, + comments_status: comments.status, + shares: shares.value, + shares_status: shares.status, + views: views.value, + views_status: views.status, + }; +} diff --git a/clis/nowcoder/metrics.test.js b/clis/nowcoder/metrics.test.js new file mode 100644 index 000000000..cad4cab86 --- /dev/null +++ b/clis/nowcoder/metrics.test.js @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { projectNowcoderMetrics } from './metrics.js'; +import './detail.js'; + +describe('Nowcoder interaction metrics', () => { + it('preserves real zero counts as available numbers', () => { + expect(projectNowcoderMetrics({ + likeCnt: 0, + followCnt: 0, + commentCnt: 0, + shareCnt: 0, + viewCnt: 0, + })).toEqual({ + likes: 0, + likes_status: 'available', + collects: 0, + collects_status: 'available', + comments: 0, + comments_status: 'available', + shares: 0, + shares_status: 'available', + views: 0, + views_status: 'available', + }); + }); + + it('projects the public collect and share counts exposed by Nowcoder', () => { + expect(projectNowcoderMetrics({ + likeCnt: 49, + followCnt: 18, + commentCnt: 83, + totalCommentCnt: 105, + shareCnt: 2, + viewCnt: 14369, + })).toMatchObject({ + likes: 49, + collects: 18, + comments: 83, + shares: 2, + views: 14369, + }); + }); + + it('uses null plus unavailable instead of inventing zero for absent metrics', () => { + expect(projectNowcoderMetrics({ + likeCnt: -1, + commentCnt: null, + totalCommentCnt: '7', + viewCnt: 'not-a-number', + })).toEqual({ + likes: null, + likes_status: 'unavailable', + collects: null, + collects_status: 'unavailable', + comments: 7, + comments_status: 'available', + shares: null, + shares_status: 'unavailable', + views: null, + views_status: 'unavailable', + }); + }); + + it('remains self-contained when serialized for browser evaluation', () => { + const serialized = Function(`return (${projectNowcoderMetrics.toString()})`)(); + expect(serialized({ likeCnt: 0, followCnt: 3, shareCnt: 1 })).toMatchObject({ + likes: 0, + likes_status: 'available', + collects: 3, + collects_status: 'available', + shares: 1, + shares_status: 'available', + }); + }); + + it('declares every metric value and status in detail output', () => { + const columns = getRegistry().get('nowcoder/detail')?.columns || []; + expect(columns).toEqual(expect.arrayContaining([ + 'likes', 'likes_status', + 'collects', 'collects_status', + 'comments', 'comments_status', + 'shares', 'shares_status', + 'views', 'views_status', + ])); + }); +}); diff --git a/docs/adapters/browser/nowcoder.md b/docs/adapters/browser/nowcoder.md index e7d91bba0..86e1f45d3 100644 --- a/docs/adapters/browser/nowcoder.md +++ b/docs/adapters/browser/nowcoder.md @@ -61,3 +61,15 @@ opencli nowcoder hot -v - **Public commands** (hot, trending, topics, recommend, creators, companies, jobs): No login required - **Cookie commands** (all others): Chrome running and **logged into** nowcoder.com, [Browser Bridge extension](/guide/browser-bridge) installed + +## Detail interaction metrics + +`nowcoder detail` exposes the five public interaction counts returned in Nowcoder's `frequencyData`: + +- `likes` comes from `likeCnt`. +- `collects` comes from `followCnt`, which the detail UI labels as 收藏. +- `comments` comes from `commentCnt`, falling back to `totalCommentCnt` when needed. +- `shares` comes from `shareCnt`. +- `views` comes from `viewCnt`. + +Each count has a matching `_status`. A present non-negative integer, including a real `0`, is returned with `available`. A missing or malformed field is returned as `null` with `unavailable`; OpenCLI does not replace unavailable data with a fabricated zero.