Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/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;
}