9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
356 lines
13 KiB
JavaScript
356 lines
13 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
import { log } from '@jackwener/opencli/logger';
|
|
import { isRecord } from '@jackwener/opencli/utils';
|
|
const XUEQIU_SYMBOL_PATTERN = /^(?:[A-Z]{2}\d{5,6}|\d{4,6}|[A-Z]{1,5}(?:[.-][A-Z]{1,2})?)$/;
|
|
const FAILURE_REASON_BY_KIND = {
|
|
auth: 'auth failure',
|
|
'anti-bot': 'anti-bot challenge',
|
|
argument: 'invalid symbol',
|
|
empty: 'no more discussion data',
|
|
incompatible: 'unexpected response shape',
|
|
unknown: 'unknown request failure',
|
|
};
|
|
function getCommentList(json) {
|
|
if (Array.isArray(json.list))
|
|
return json.list;
|
|
if (isRecord(json.data) && Array.isArray(json.data.list))
|
|
return json.data.list;
|
|
return null;
|
|
}
|
|
function isAntiBotHtml(response, envelopeText) {
|
|
const htmlText = `${envelopeText} ${response.textSnippet}`.toLowerCase();
|
|
return response.contentType.includes('text/html')
|
|
&& (/captcha|challenge|aliyun_waf|risk/i.test(htmlText)
|
|
|| /_WAF_|_waf_|renderData|aliyun_waf/i.test(response.textSnippet));
|
|
}
|
|
function toFiniteCount(value) {
|
|
const count = Number(value ?? 0);
|
|
return Number.isFinite(count) ? count : 0;
|
|
}
|
|
function normalizeIdentifier(value) {
|
|
if (typeof value === 'string')
|
|
return value.trim();
|
|
if (typeof value === 'number' && Number.isFinite(value))
|
|
return String(value);
|
|
return '';
|
|
}
|
|
function buildPaginationStopMessage(requestNumber, collected, target, reason) {
|
|
return `xueqiu comments pagination stopped after request ${requestNumber}, `
|
|
+ `collected ${collected}/${target} items, `
|
|
+ `reason: ${reason}`;
|
|
}
|
|
function throwFirstPageFailure(kind, symbol) {
|
|
if (kind === 'auth' || kind === 'anti-bot') {
|
|
throw new AuthRequiredError('xueqiu.com', 'Stock discussions require login or challenge clearance');
|
|
}
|
|
if (kind === 'argument') {
|
|
throw new ArgumentError(`xueqiu comments received an invalid symbol: ${symbol}`);
|
|
}
|
|
if (kind === 'empty') {
|
|
throw new EmptyResultError(`xueqiu/comments ${symbol}`, `No discussion data found for ${symbol}`);
|
|
}
|
|
throw new CommandExecutionError(`Unexpected response while loading xueqiu comments for ${symbol}`, 'Run the command again with --verbose to inspect the raw site response.');
|
|
}
|
|
/**
|
|
* Extract the raw item list from one classified JSON payload.
|
|
*
|
|
* @param json Raw parsed JSON payload from browser fetch.
|
|
* @returns Discussion items when the response shape is usable.
|
|
*/
|
|
export function getCommentItems(json) {
|
|
if (!isRecord(json))
|
|
return [];
|
|
const list = getCommentList(json) ?? [];
|
|
return list.filter((item) => !!item && typeof item === 'object');
|
|
}
|
|
/**
|
|
* Classify one raw browser response before command-level error handling.
|
|
*
|
|
* @param response Structured browser response payload.
|
|
* @returns Tagged result describing the response class.
|
|
*/
|
|
export function classifyXueqiuCommentsResponse(response) {
|
|
const jsonRecord = isRecord(response.json) ? response.json : null;
|
|
const commentList = jsonRecord ? getCommentList(jsonRecord) : null;
|
|
const envelopeText = [
|
|
jsonRecord?.error,
|
|
jsonRecord?.errors,
|
|
jsonRecord?.code,
|
|
jsonRecord?.message,
|
|
jsonRecord?.msg,
|
|
].filter(Boolean).join(' ').toLowerCase();
|
|
const responseText = `${envelopeText} ${response.textSnippet}`.toLowerCase();
|
|
if (isAntiBotHtml(response, envelopeText)) {
|
|
return { kind: 'anti-bot' };
|
|
}
|
|
if (response.status === 401 || response.status === 403) {
|
|
return { kind: 'auth' };
|
|
}
|
|
if (/login required|unauthorized|unauthorised|forbidden|not logged in|need login/.test(responseText)) {
|
|
return { kind: 'auth' };
|
|
}
|
|
if (/invalid symbol|invalid code|bad symbol/.test(envelopeText)) {
|
|
return { kind: 'argument' };
|
|
}
|
|
if (/no data|no result|not found|no matching/.test(envelopeText)) {
|
|
return { kind: 'empty' };
|
|
}
|
|
if (commentList && commentList.length === 0) {
|
|
return { kind: 'empty' };
|
|
}
|
|
if (response.contentType.includes('application/json') && jsonRecord && commentList === null) {
|
|
return { kind: 'incompatible' };
|
|
}
|
|
return { kind: 'unknown' };
|
|
}
|
|
/**
|
|
* Merge one new page of rows while preserving the first occurrence of each ID.
|
|
*
|
|
* @param current Rows already collected.
|
|
* @param incoming Rows from the next page.
|
|
* @returns Deduplicated merged rows.
|
|
*/
|
|
export function mergeUniqueCommentRows(current, incoming) {
|
|
const merged = [...current];
|
|
const seen = new Set(current.map(item => item.id));
|
|
for (const row of incoming) {
|
|
if (seen.has(row.id))
|
|
continue;
|
|
seen.add(row.id);
|
|
merged.push(row);
|
|
}
|
|
return merged;
|
|
}
|
|
/**
|
|
* Normalize one raw xueqiu discussion item into the CLI row shape.
|
|
*
|
|
* Returned rows represent stock-scoped discussion posts, not replies under
|
|
* one parent post.
|
|
*
|
|
* @param item Raw API item.
|
|
* @returns Cleaned CLI row.
|
|
*/
|
|
export function normalizeCommentItem(item) {
|
|
const text = String(item.description ?? '')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
const id = normalizeIdentifier(item.id);
|
|
const userId = normalizeIdentifier(item.user?.id);
|
|
const createdAtDate = item.created_at ? new Date(item.created_at) : null;
|
|
const createdAt = createdAtDate && !Number.isNaN(createdAtDate.getTime())
|
|
? createdAtDate.toISOString()
|
|
: null;
|
|
return {
|
|
id,
|
|
author: String(item.user?.screen_name ?? ''),
|
|
text,
|
|
likes: toFiniteCount(item.fav_count),
|
|
replies: toFiniteCount(item.reply_count),
|
|
retweets: toFiniteCount(item.retweet_count),
|
|
created_at: createdAt,
|
|
url: userId && id ? `https://xueqiu.com/${userId}/${id}` : null,
|
|
};
|
|
}
|
|
/**
|
|
* Remove internal-only fields before returning rows to the CLI renderer.
|
|
*
|
|
* @param row Internal row shape used during pagination.
|
|
* @returns Public output row that matches the documented command contract.
|
|
*/
|
|
export function toCommentOutputRow(row) {
|
|
const { id: _id, ...outputRow } = row;
|
|
return outputRow;
|
|
}
|
|
/**
|
|
* Convert response classification into a compact warning phrase.
|
|
*
|
|
* @param kind Classifier result kind.
|
|
* @returns Human-readable reason fragment for stderr warnings.
|
|
*/
|
|
export function describeFailureKind(kind) {
|
|
return FAILURE_REASON_BY_KIND[kind];
|
|
}
|
|
/**
|
|
* Fetch one discussion page from inside the browser context so cookies and
|
|
* any site-side request state stay attached to the request.
|
|
*
|
|
* @param page Active browser page.
|
|
* @param symbol Normalized stock symbol.
|
|
* @param pageNumber Internal page counter, starting from 1.
|
|
* @param pageSize Item count per internal request.
|
|
* @returns Structured response for command-side classification.
|
|
*/
|
|
export async function fetchCommentsPage(page, symbol, pageNumber, pageSize) {
|
|
const url = new URL('https://xueqiu.com/query/v1/symbol/search/status');
|
|
url.searchParams.set('symbol', symbol);
|
|
url.searchParams.set('count', String(pageSize));
|
|
url.searchParams.set('page', String(pageNumber));
|
|
url.searchParams.set('sort', 'time');
|
|
return page.evaluate(`
|
|
(async () => {
|
|
try {
|
|
const response = await fetch(${JSON.stringify(url.toString())}, {
|
|
credentials: 'include',
|
|
headers: {
|
|
'accept': 'application/json, text/plain, */*',
|
|
'x-requested-with': 'XMLHttpRequest',
|
|
},
|
|
referrer: ${JSON.stringify(`https://xueqiu.com/S/${symbol}`)},
|
|
referrerPolicy: 'strict-origin-when-cross-origin',
|
|
});
|
|
const contentType = response.headers.get('content-type') || '';
|
|
const text = await response.text();
|
|
let json = null;
|
|
if (contentType.includes('application/json')) {
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
json = null;
|
|
}
|
|
}
|
|
return {
|
|
status: response.status,
|
|
contentType,
|
|
json,
|
|
textSnippet: text.slice(0, 2000),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
status: 0,
|
|
contentType: 'text/plain',
|
|
json: null,
|
|
textSnippet: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
})()
|
|
`);
|
|
}
|
|
/**
|
|
* Collect enough stock discussion rows to satisfy the requested limit.
|
|
*
|
|
* This helper owns the internal pagination policy so the public command
|
|
* contract can stay small and expose only `--limit`.
|
|
*
|
|
* @param options Pagination inputs and a page-fetch callback.
|
|
* @returns Deduplicated normalized rows, possibly partial with a warning.
|
|
*/
|
|
export async function collectCommentRows(options) {
|
|
const warn = options.warn ?? log.warn;
|
|
let rows = [];
|
|
const seenIds = new Set();
|
|
for (let requestNumber = 1; requestNumber <= options.maxRequests; requestNumber += 1) {
|
|
const response = await options.fetchPage(requestNumber, options.pageSize);
|
|
const classified = classifyXueqiuCommentsResponse(response);
|
|
if (requestNumber === 1 && classified.kind !== 'unknown') {
|
|
throwFirstPageFailure(classified.kind, options.symbol);
|
|
}
|
|
else if (classified.kind === 'empty') {
|
|
break;
|
|
}
|
|
else if (classified.kind !== 'unknown') {
|
|
warn(buildPaginationStopMessage(requestNumber, rows.length, options.limit, describeFailureKind(classified.kind)));
|
|
break;
|
|
}
|
|
const rawItems = getCommentItems(response.json);
|
|
const pageRows = rawItems
|
|
.map(item => normalizeCommentItem(item))
|
|
.filter(row => row.id);
|
|
if (pageRows.length === 0) {
|
|
if (requestNumber === 1) {
|
|
throw new CommandExecutionError(`Unexpected response while loading xueqiu comments for ${options.symbol}`, 'Run the command again with --verbose to inspect the raw site response.');
|
|
}
|
|
if (classified.kind === 'unknown') {
|
|
warn(buildPaginationStopMessage(requestNumber, rows.length, options.limit, describeFailureKind(classified.kind)));
|
|
}
|
|
break;
|
|
}
|
|
let advanced = false;
|
|
for (const row of pageRows) {
|
|
if (seenIds.has(row.id))
|
|
continue;
|
|
seenIds.add(row.id);
|
|
rows.push(row);
|
|
advanced = true;
|
|
}
|
|
if (rows.length >= options.limit) {
|
|
return rows.slice(0, options.limit);
|
|
}
|
|
if (rawItems.length < options.pageSize) {
|
|
break;
|
|
}
|
|
if (!advanced) {
|
|
warn(buildPaginationStopMessage(requestNumber, rows.length, options.limit, 'pagination did not advance'));
|
|
break;
|
|
}
|
|
if (requestNumber === options.maxRequests) {
|
|
warn(buildPaginationStopMessage(requestNumber, rows.length, options.limit, 'reached safety cap'));
|
|
}
|
|
}
|
|
return rows.slice(0, options.limit);
|
|
}
|
|
cli({
|
|
site: 'xueqiu',
|
|
name: 'comments',
|
|
access: 'read',
|
|
description: '获取单只股票的讨论动态',
|
|
domain: 'xueqiu.com',
|
|
strategy: Strategy.COOKIE,
|
|
browser: true,
|
|
navigateBefore: false,
|
|
args: [
|
|
{
|
|
name: 'symbol',
|
|
positional: true,
|
|
required: true,
|
|
help: 'Stock symbol, e.g. SH600519, AAPL, or 00700',
|
|
},
|
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of discussion posts to return' },
|
|
],
|
|
columns: ['author', 'text', 'likes', 'replies', 'retweets', 'created_at', 'url'],
|
|
func: async (page, args) => {
|
|
const symbol = normalizeSymbolInput(args.symbol);
|
|
const limit = Number(args.limit);
|
|
if (!Number.isInteger(limit) || limit <= 0) {
|
|
throw new ArgumentError('xueqiu comments requires --limit to be a positive integer');
|
|
}
|
|
if (limit > 100) {
|
|
throw new ArgumentError('xueqiu comments supports --limit up to 100');
|
|
}
|
|
const pageSize = Math.min(limit, 20);
|
|
await page.goto('https://xueqiu.com');
|
|
const rows = await collectCommentRows({
|
|
symbol,
|
|
limit,
|
|
pageSize,
|
|
maxRequests: 5,
|
|
fetchPage: (pageNumber, currentPageSize) => fetchCommentsPage(page, symbol, pageNumber, currentPageSize),
|
|
warn: log.warn,
|
|
});
|
|
return rows.map(row => toCommentOutputRow(row));
|
|
},
|
|
});
|
|
/**
|
|
* Convert raw CLI input into a normalized stock symbol.
|
|
*
|
|
* @param raw User-provided CLI argument.
|
|
* @returns Upper-cased symbol string.
|
|
*/
|
|
export function normalizeSymbolInput(raw) {
|
|
const symbol = String(raw ?? '').trim().toUpperCase();
|
|
if (!symbol)
|
|
throw new ArgumentError('xueqiu comments requires a symbol');
|
|
if (/^HTTPS?:\/\//.test(symbol)) {
|
|
throw new ArgumentError('xueqiu comments only accepts a symbol, not a URL');
|
|
}
|
|
if (!XUEQIU_SYMBOL_PATTERN.test(symbol)) {
|
|
throw new ArgumentError(`xueqiu comments received an invalid symbol: ${symbol}`);
|
|
}
|
|
return symbol;
|
|
}
|