@clawhub-etherstrings-068097b43a
Use when the user wants GPT-Image-2 image generation or image-to-image through an official OpenAI permission code/API key, a custom Responses-compatible prox...
---
name: autoGenImageSkill
version: "0.1.2"
description: "Use when the user wants GPT-Image-2 image generation or image-to-image through an official OpenAI permission code/API key, a custom Responses-compatible proxy, or a reserved purchased-capacity relay."
homepage: https://github.com/Etherstrings/autoGenImageSkill#donate
metadata:
openclaw:
requires:
bins: ["node"]
---
# autoGenImageSkill
## Overview
Use this OpenClaw skill to generate PNG images with the local `gpt_image` relay pattern: a Responses API request uses text model `gpt-5.4` plus an `image_generation` tool using `gpt-image-2`, then writes the returned base64 image to disk. The bundled CLI exposes three access paths so agents can pick the right entry without rewriting fetch/SSE/image decoding logic.
The main script is [scripts/gpt_image_cli.js](scripts/gpt_image_cli.js). Run it with Node 18+. In OpenClaw, reference it as `{baseDir}/scripts/gpt_image_cli.js` so the command works wherever the skill folder is located.
External pages:
- ClawHub / OpenClaw: `https://clawhub.ai/Etherstrings/autogenimageskill`
- Hermes Agent GitHub skill source: `https://github.com/Etherstrings/autoGenImageSkill/tree/main/autoGenImageSkill`
## 赞助支持
- 爱发电: `https://ifdian.net/a/etherstrings`
- GitHub donate section: `https://github.com/Etherstrings/autoGenImageSkill#donate`
Alipay:

WeChat Pay:

## Access Choice
1. Use `official` when the user provides an official OpenAI permission code/API key or explicitly wants the official API path.
2. Use `proxy` when the user provides a custom `base_url`, proxy endpoint, provider name, or third-party Responses-compatible API key.
3. Use `reserved` when the user wants to use the creator's reserved capacity, purchase/redeem a key, check quota, or call the relay service that exposes `/api/session`, `/api/keys`, and `/api/generate/jobs`.
Do not echo API keys, permission codes, purchase keys, or provider tokens back to the user. Use environment variables or shell variables in examples.
## Quick Commands
Official API key / permission code:
```bash
node {baseDir}/scripts/gpt_image_cli.js generate \
--mode official \
--permission-code "$OPENAI_API_KEY" \
--prompt "一张电影感的雨夜赛博城市街景" \
--output output/cyber-rain.png
```
Custom proxy:
```bash
node {baseDir}/scripts/gpt_image_cli.js generate \
--mode proxy \
--base-url "$GPT_IMAGE_BASE_URL" \
--api-key "$GPT_IMAGE_API_KEY" \
--prompt "透明背景的可爱机器人贴纸" \
--size 1024x1024 \
--output output/robot-sticker.png
```
Reserved purchased capacity:
```bash
node {baseDir}/scripts/gpt_image_cli.js generate \
--mode reserved \
--service-url "$GPT_IMAGE_RELAY_URL" \
--purchase-key "$GPT_IMAGE_PURCHASE_KEY" \
--prompt "国风水墨质感的未来城市海报" \
--output output/ink-future-city.png
```
Image-to-image:
```bash
node {baseDir}/scripts/gpt_image_cli.js generate \
--mode proxy \
--base-url "$GPT_IMAGE_BASE_URL" \
--api-key "$GPT_IMAGE_API_KEY" \
--prompt "保持人物姿势,改成高端杂志封面摄影" \
--image /absolute/path/reference.png \
--output output/cover.png
```
## Reserved Flow
For reserved capacity, create or reuse a session before generation when the user wants account persistence:
```bash
node {baseDir}/scripts/gpt_image_cli.js session \
--service-url "$GPT_IMAGE_RELAY_URL" \
--profile-name "demo-user" \
--save-session
```
Redeem a purchase key without generating:
```bash
node {baseDir}/scripts/gpt_image_cli.js redeem \
--service-url "$GPT_IMAGE_RELAY_URL" \
--purchase-key "$GPT_IMAGE_PURCHASE_KEY" \
--user-id "$GPT_IMAGE_USER_ID"
```
Check quota:
```bash
node {baseDir}/scripts/gpt_image_cli.js quota \
--service-url "$GPT_IMAGE_RELAY_URL" \
--user-id "$GPT_IMAGE_USER_ID"
```
## References
- Read [references/access-modes.md](references/access-modes.md) when choosing among official, proxy, and reserved entries or when a user asks how to configure them.
- Read [references/runtime.md](references/runtime.md) when debugging generation, SSE parsing, relay quota, OpenClaw/Hermes packaging, or the relationship to the original `gpt_image` project.
## Output Rules
Always return the absolute output image path and the decisive metadata: access mode, endpoint or relay job ID, provider name when available, byte size, and any revised prompt returned by the model. Keep credentials redacted.
FILE:agents/openai.yaml
interface:
display_name: "autoGenImageSkill"
short_description: "用官方密钥、自定义代理或预留额度生成 GPT Image 图片。"
default_prompt: "Use $autoGenImageSkill to generate an image from a prompt through an official API key, a custom Responses-compatible proxy, or reserved purchased capacity."
policy:
allow_implicit_invocation: true
FILE:references/access-modes.md
# Access Modes
Use the same image payload for all direct Responses-compatible entries:
```json
{
"model": "gpt-5.4",
"input": "prompt or multimodal user content",
"tools": [
{
"type": "image_generation",
"model": "gpt-image-2",
"size": "1024x1536",
"quality": "high",
"output_format": "png"
}
],
"tool_choice": { "type": "image_generation" },
"stream": true
}
```
## official
Use when the user has an official OpenAI permission code/API key.
Accepted inputs:
- `--permission-code` or `--api-key`
- `OPENAI_API_KEY`
- Optional `--base-url` or `OPENAI_BASE_URL`, defaulting to `https://api.openai.com/v1`
Example:
```bash
node {baseDir}/scripts/gpt_image_cli.js generate \
--mode official \
--permission-code "$OPENAI_API_KEY" \
--prompt "一张白底产品海报,主体是一台透明外壳复古收音机" \
--output output/radio.png
```
## proxy
Use when the user has a Responses-compatible proxy or aggregator.
Accepted inputs:
- `--base-url` or `GPT_IMAGE_BASE_URL`
- `--api-key` or `GPT_IMAGE_API_KEY`
- Optional `--provider-name`
The script accepts either a full `/responses` URL or a base URL such as `/v1`; it tries reasonable endpoint candidates.
Example:
```bash
node {baseDir}/scripts/gpt_image_cli.js generate \
--mode proxy \
--base-url "$GPT_IMAGE_BASE_URL" \
--api-key "$GPT_IMAGE_API_KEY" \
--prompt "一套极简 App 图标,玻璃拟态,蓝绿配色" \
--size 1024x1024 \
--output output/app-icon.png
```
## reserved
Use when the user wants to consume the creator's reserved capacity through a relay service.
Accepted inputs:
- `--service-url` or `GPT_IMAGE_RELAY_URL`
- `--user-id` or `GPT_IMAGE_USER_ID`
- `--profile-name` or `GPT_IMAGE_PROFILE_NAME`
- `--purchase-key` or `GPT_IMAGE_PURCHASE_KEY`
- `--quota auto|free|credit|none`, default `auto`
Reserved mode calls:
1. `POST /api/session` to create/reuse a user.
2. `POST /api/session/register` if `--profile-name` is provided.
3. `POST /api/keys` with `validate` and `consume` if `--purchase-key` is provided.
4. `POST /api/keys` with `check_free`, `consume_free`, or `consume_credit` according to quota mode.
5. `POST /api/generate/jobs`, then polls `GET /api/generate/jobs/:id`.
6. Downloads `GET /api/generate/jobs/:id/image` after success.
Example:
```bash
node {baseDir}/scripts/gpt_image_cli.js generate \
--mode reserved \
--service-url "$GPT_IMAGE_RELAY_URL" \
--purchase-key "$GPT_IMAGE_PURCHASE_KEY" \
--profile-name "alice" \
--prompt "厚涂风格的幻想角色立绘,半身像" \
--output output/portrait.png \
--save-session
```
For image-to-image reserved generation, provide `--image /absolute/path/input.png`. The script converts it to a data URL before submitting the job.
## Common Options
- `--prompt`: Required for `generate`.
- `--image`: Optional image path or `data:image/...` URL for image-to-image.
- `--output`: Output PNG path, default `generated-image.png`.
- `--model`: Text model, default `gpt-5.4`.
- `--image-model`: Image model, default `gpt-image-2`.
- `--size`: Default `1024x1536`.
- `--quality`: Default `high`.
- `--output-format`: Default `png`.
- `--retries`: Direct official/proxy retry count, default `3`.
- `--timeout-ms`: Reserved job polling timeout, default `180000`.
Never place real secrets in the skill files. Pass them at runtime through environment variables, local shell variables, or the user's secret manager.
FILE:references/runtime.md
# Runtime Notes
## Relationship to `gpt_image`
The local `gpt_image` project has two relevant implementations:
- `frontend/generate-gpt-image2.js`: a direct one-shot Node script that posts to a Responses endpoint, streams SSE, extracts the final `image_generation_call.result`, and writes a PNG.
- `frontend/server.js`: a long-running relay service with provider fallback, job polling, image-to-image support, sessions, free quota, purchase-key redemption, and provider admin endpoints.
This skill preserves the same core generation contract but keeps credentials out of the skill package.
## Direct Generation Contract
Direct `official` and `proxy` modes:
1. Build a Responses payload with `model: gpt-5.4`.
2. Add a single tool `{ "type": "image_generation", "model": "gpt-image-2" }`.
3. Force `tool_choice` to `image_generation`.
4. Request `stream: true`.
5. Parse SSE events until an `image_generation_call` output item contains `result`.
6. Decode `result` as base64 and write the PNG.
If an input image is provided, send `input` as a user message with `input_text` and `input_image`.
## Relay Generation Contract
Reserved mode assumes a relay shaped like the local `frontend/server.js`:
- `POST /api/session`
- `POST /api/session/register`
- `POST /api/keys`
- `POST /api/generate/jobs`
- `GET /api/generate/jobs/:jobId`
- `GET /api/generate/jobs/:jobId/image`
The relay may protect job status and image download with `X-User-Id`; keep this header whenever a user ID is available.
## OpenClaw and Hermes Packaging
The reference `tonghuashun-ifind-skill` uses a GitHub repo whose actual skill root is a subdirectory containing:
- `SKILL.md`
- `agents/openai.yaml`
- `scripts/*`
- `references/*`
OpenClaw and ClawHub expect a skill folder with `SKILL.md` plus optional supporting text files. This project follows that source shape: the publishable skill root is `autoGenImageSkill/`. Keep this repository as generated source unless the user explicitly asks for an installation step later.
The `SKILL.md` frontmatter includes:
- `name`
- `version`
- `description`
- `metadata.openclaw.requires.bins: ["node"]`
Do not add an install script or copy files into an OpenClaw skill directory unless explicitly requested.
## Security and Logging
- Do not print API keys, permission codes, purchase keys, or provider tokens.
- Do not copy the original `providers.json` secrets into this skill.
- Summaries may include endpoint host/path, provider name, job ID, output path, byte count, and revised prompt.
- If a relay returns detailed provider failures, summarize only status, provider, retryability, and short error text.
FILE:scripts/gpt_image_cli.js
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const DEFAULT_MODEL = 'gpt-5.4';
const DEFAULT_IMAGE_MODEL = 'gpt-image-2';
const DEFAULT_SIZE = '1024x1536';
const DEFAULT_QUALITY = 'high';
const DEFAULT_OUTPUT_FORMAT = 'png';
const DEFAULT_DIRECT_RETRIES = 3;
const DEFAULT_TIMEOUT_MS = 180000;
const DEFAULT_POLL_INTERVAL_MS = 1500;
const DEFAULT_STATE_PATH = path.join(os.homedir(), '.openclaw', 'autoGenImageSkill', 'state.json');
function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith('--')) {
args._.push(token);
continue;
}
const eqIndex = token.indexOf('=');
if (eqIndex > 2) {
args[token.slice(2, eqIndex)] = token.slice(eqIndex + 1);
continue;
}
const key = token.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
args[key] = true;
continue;
}
args[key] = next;
i += 1;
}
return args;
}
function help() {
return `autoGenImageSkill CLI
Usage:
node gpt_image_cli.js generate --mode official --permission-code "$OPENAI_API_KEY" --prompt "..." --output out.png
node gpt_image_cli.js generate --mode proxy --base-url "$GPT_IMAGE_BASE_URL" --api-key "$GPT_IMAGE_API_KEY" --prompt "..."
node gpt_image_cli.js generate --mode reserved --service-url "$GPT_IMAGE_RELAY_URL" --purchase-key "$GPT_IMAGE_PURCHASE_KEY" --prompt "..."
node gpt_image_cli.js session --service-url "$GPT_IMAGE_RELAY_URL" --profile-name "demo" --save-session
node gpt_image_cli.js redeem --service-url "$GPT_IMAGE_RELAY_URL" --purchase-key "$GPT_IMAGE_PURCHASE_KEY" --user-id "$GPT_IMAGE_USER_ID"
node gpt_image_cli.js quota --service-url "$GPT_IMAGE_RELAY_URL" --user-id "$GPT_IMAGE_USER_ID"
Common generate options:
--prompt TEXT
--image PATH_OR_DATA_URL
--output PATH default: generated-image.png
--model NAME default: gpt-5.4
--image-model NAME default: gpt-image-2
--size WxH default: 1024x1536
--quality VALUE default: high
--output-format VALUE default: png
--retries N direct official/proxy retries, default: 3
--timeout-ms N reserved job timeout, default: 180000
Secrets are read from arguments or environment variables and are never printed.`;
}
function readState(args = {}) {
const statePath = String(args['state-path'] || process.env.GPT_IMAGE_STATE_PATH || DEFAULT_STATE_PATH);
try {
if (!fs.existsSync(statePath)) return { statePath, state: {} };
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
return { statePath, state: parsed && typeof parsed === 'object' ? parsed : {} };
} catch {
return { statePath, state: {} };
}
}
function saveState(statePath, nextState) {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, JSON.stringify(nextState, null, 2), 'utf8');
}
function stringValue(...values) {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value.trim();
}
return '';
}
function integerValue(value, fallback) {
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function boolValue(value) {
return value === true || ['1', 'true', 'yes', 'on'].includes(String(value || '').toLowerCase());
}
function requireValue(name, value) {
if (!value) {
throw new Error(`Missing required value: name`);
}
return value;
}
function unique(values) {
const seen = new Set();
const output = [];
for (const value of values) {
if (!value || seen.has(value)) continue;
seen.add(value);
output.push(value);
}
return output;
}
function normalizeDirectEndpointCandidates(baseUrl) {
const normalized = String(baseUrl || '').trim().replace(/\/+$/, '');
if (!normalized) return [];
const candidates = [];
if (/\/responses$/i.test(normalized)) {
candidates.push(normalized);
} else if (/\/v\d+$/i.test(normalized) || /\/openai\/v\d+$/i.test(normalized)) {
candidates.push(`normalized/responses`);
} else if (/api\.openai\.com$/i.test(normalized)) {
candidates.push(`normalized/v1/responses`);
} else {
candidates.push(`normalized/responses`);
candidates.push(`normalized/v1/responses`);
}
candidates.push(normalized.replace(/\/openai\/v1\/responses$/i, '/v1/responses'));
candidates.push(normalized.replace(/\/openai\/v1$/i, '/v1/responses'));
candidates.push(normalized.replace(/\/v1$/i, '/v1/responses'));
return unique(candidates);
}
function normalizeServiceRoot(serviceUrl) {
let root = String(serviceUrl || '').trim().replace(/\/+$/, '');
root = root.replace(/\/api\/generate\/jobs(?:\/.*)?$/i, '');
root = root.replace(/\/api\/generate(?:-image)?$/i, '');
root = root.replace(/\/api\/keys$/i, '');
root = root.replace(/\/api\/session(?:\/register)?$/i, '');
return root;
}
function serviceUrl(root, apiPath) {
const normalizedRoot = normalizeServiceRoot(root);
return `normalizedRoot`/${apiPath`}`;
}
function resolveRelayImageUrl(root, imageUrl) {
if (/^(data:|https?:\/\/|blob:)/i.test(imageUrl)) {
return imageUrl;
}
if (imageUrl.startsWith('/')) {
return `normalizeServiceRoot(root)imageUrl`;
}
return `normalizeServiceRoot(root)/imageUrl`;
}
function mimeFromPath(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
if (ext === '.webp') return 'image/webp';
if (ext === '.gif') return 'image/gif';
return 'image/png';
}
function readImageDataUrl(value) {
if (!value) return null;
if (String(value).startsWith('data:image/')) return String(value);
const absolutePath = path.resolve(String(value));
const buffer = fs.readFileSync(absolutePath);
return `data:mimeFromPath(absolutePath);base64,buffer.toString('base64')`;
}
function buildPayload(args, inputImage) {
const prompt = requireValue('prompt', stringValue(args.prompt, process.env.PROMPT));
const imageModel = stringValue(args['image-model'], process.env.GPT_IMAGE_MODEL) || DEFAULT_IMAGE_MODEL;
const outputFormat = stringValue(args['output-format'], process.env.GPT_IMAGE_OUTPUT_FORMAT) || DEFAULT_OUTPUT_FORMAT;
return {
model: stringValue(args.model, process.env.GPT_IMAGE_TEXT_MODEL) || DEFAULT_MODEL,
input: inputImage
? [
{
role: 'user',
content: [
{ type: 'input_text', text: prompt },
{ type: 'input_image', image_url: inputImage },
],
},
]
: prompt,
tools: [
{
type: 'image_generation',
model: imageModel,
size: stringValue(args.size, process.env.GPT_IMAGE_SIZE) || DEFAULT_SIZE,
quality: stringValue(args.quality, process.env.GPT_IMAGE_QUALITY) || DEFAULT_QUALITY,
output_format: outputFormat,
},
],
tool_choice: { type: 'image_generation' },
stream: true,
};
}
async function readSseResult(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const result = {
responseId: null,
createdTool: null,
finalCall: null,
outputText: '',
error: null,
};
function captureOutputItem(item) {
if (!item || typeof item !== 'object') return;
if (item.type === 'image_generation_call') {
result.finalCall = item;
return;
}
if (item.type === 'message' && Array.isArray(item.content)) {
for (const part of item.content) {
if (part.type === 'output_text' && part.text) {
result.outputText += part.text;
}
}
}
}
function handleEvent(obj) {
if (obj.response && obj.response.id) {
result.responseId = obj.response.id;
}
if (
(obj.type === 'response.created' || obj.type === 'response.in_progress') &&
obj.response &&
Array.isArray(obj.response.tools) &&
obj.response.tools[0] &&
!result.createdTool
) {
result.createdTool = obj.response.tools[0];
}
if (obj.type === 'response.output_text.delta' && obj.delta) {
result.outputText += obj.delta;
}
if (obj.type === 'response.output_item.done' && obj.item) {
captureOutputItem(obj.item);
}
if (
(obj.type === 'response.completed' || obj.type === 'response.incomplete') &&
obj.response &&
Array.isArray(obj.response.output)
) {
for (const item of obj.response.output) {
captureOutputItem(item);
}
}
if (obj.type === 'error' && obj.error) {
result.error = obj.error;
}
if (obj.type === 'response.failed' && obj.response && obj.response.error && !result.error) {
result.error = obj.response.error;
}
}
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let splitIndex;
while ((splitIndex = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, splitIndex);
buffer = buffer.slice(splitIndex + 2);
const lines = block.split(/\r?\n/);
const dataLines = [];
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
const dataText = dataLines.join('\n');
if (!dataText || dataText === '[DONE]') continue;
try {
handleEvent(JSON.parse(dataText));
} catch {
// Ignore malformed chunks from intermediary relays.
}
}
}
return result;
}
function findImageGenerationCall(obj) {
if (!obj || typeof obj !== 'object') return null;
if (obj.type === 'image_generation_call' && typeof obj.result === 'string') return obj;
if (Array.isArray(obj)) {
for (const item of obj) {
const found = findImageGenerationCall(item);
if (found) return found;
}
return null;
}
for (const value of Object.values(obj)) {
const found = findImageGenerationCall(value);
if (found) return found;
}
return null;
}
function summarizeFailure(failure) {
if (!failure) return null;
const copy = { ...failure };
if (typeof copy.body === 'string' && copy.body.length > 600) {
copy.body = `copy.body.slice(0, 600)...`;
}
if (copy.error && typeof copy.error === 'object') {
copy.error = JSON.stringify(copy.error).slice(0, 600);
}
return copy;
}
async function tryDirectEndpoint(endpoint, apiKey, payload) {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer apiKey`,
'Content-Type': 'application/json',
Accept: 'text/event-stream, application/json',
},
body: JSON.stringify(payload),
});
const contentType = response.headers.get('content-type') || '';
if (!response.ok) {
return {
ok: false,
endpoint,
status: response.status,
contentType,
body: await response.text(),
retryable: response.status === 408 || response.status === 409 || response.status === 425 || response.status === 429 || response.status >= 500,
};
}
if (contentType.includes('text/event-stream')) {
const sse = await readSseResult(response);
const finalCall = sse.finalCall;
if (finalCall && finalCall.result) {
return {
ok: true,
endpoint,
imageBase64: finalCall.result,
meta: {
responseId: sse.responseId,
createdTool: sse.createdTool,
finalCall,
outputText: sse.outputText || '',
},
};
}
return {
ok: false,
endpoint,
status: response.status,
contentType,
error: sse.error || 'SSE finished without image_generation_call.result',
retryable: true,
};
}
const text = await response.text();
let parsed = null;
try {
parsed = JSON.parse(text);
} catch {
return {
ok: false,
endpoint,
status: response.status,
contentType,
body: text,
retryable: false,
};
}
const finalCall = findImageGenerationCall(parsed);
if (finalCall && finalCall.result) {
return {
ok: true,
endpoint,
imageBase64: finalCall.result,
meta: {
responseId: parsed.id || parsed.response?.id || null,
createdTool: Array.isArray(parsed.tools) ? parsed.tools[0] : null,
finalCall,
outputText: '',
},
};
}
return {
ok: false,
endpoint,
status: response.status,
contentType,
body: text,
retryable: false,
};
}
async function generateDirect(args, mode) {
const apiKey =
mode === 'official'
? stringValue(args['permission-code'], args['api-key'], process.env.OPENAI_API_KEY, process.env.GPT_IMAGE_OFFICIAL_PERMISSION_CODE)
: stringValue(args['api-key'], args['permission-code'], process.env.GPT_IMAGE_API_KEY);
requireValue(mode === 'official' ? 'permission-code or OPENAI_API_KEY' : 'api-key or GPT_IMAGE_API_KEY', apiKey);
const baseUrl =
mode === 'official'
? stringValue(args['base-url'], process.env.OPENAI_BASE_URL) || 'https://api.openai.com/v1'
: requireValue('base-url or GPT_IMAGE_BASE_URL', stringValue(args['base-url'], process.env.GPT_IMAGE_BASE_URL));
const inputImage = readImageDataUrl(stringValue(args.image, process.env.GPT_IMAGE_INPUT_IMAGE));
const payload = buildPayload(args, inputImage);
const endpoints = normalizeDirectEndpointCandidates(baseUrl);
const retries = Math.max(1, integerValue(args.retries || process.env.GPT_IMAGE_RETRIES, DEFAULT_DIRECT_RETRIES));
let lastFailure = null;
for (let attempt = 1; attempt <= retries; attempt += 1) {
for (const endpoint of endpoints) {
try {
const result = await tryDirectEndpoint(endpoint, apiKey, payload);
if (result.ok) {
return {
...result,
mode,
providerName: stringValue(args['provider-name']) || (mode === 'official' ? 'official' : 'proxy'),
attempt,
};
}
lastFailure = { ...result, attempt };
if (result.retryable === false) break;
} catch (error) {
lastFailure = {
endpoint,
attempt,
error: String(error),
retryable: true,
};
}
}
}
throw new Error(`Generation failed: JSON.stringify(summarizeFailure(lastFailure))`);
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, options);
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { text };
}
if (!response.ok) {
const message = body && typeof body.error === 'string' ? body.error : `HTTP response.status`;
const error = new Error(message);
error.status = response.status;
error.body = body;
throw error;
}
return body || {};
}
async function postRelayJson(root, apiPath, body, userId = '') {
const headers = { 'Content-Type': 'application/json' };
if (userId) headers['X-User-Id'] = userId;
return fetchJson(serviceUrl(root, apiPath), {
method: 'POST',
headers,
body: JSON.stringify(body || {}),
});
}
async function getRelayJson(root, apiPath, userId = '') {
const headers = {};
if (userId) headers['X-User-Id'] = userId;
return fetchJson(serviceUrl(root, apiPath), { headers });
}
async function downloadRelayImage(root, imageUrl, userId = '') {
const headers = {};
if (userId) headers['X-User-Id'] = userId;
const response = await fetch(resolveRelayImageUrl(root, imageUrl), { headers });
if (!response.ok) {
throw new Error(`Image download failed: HTTP response.status`);
}
return Buffer.from(await response.arrayBuffer());
}
async function ensureRelaySession(args, options = {}) {
const { statePath, state } = readState(args);
const serviceRoot = normalizeServiceRoot(
requireValue(
'service-url or GPT_IMAGE_RELAY_URL',
stringValue(args['service-url'], process.env.GPT_IMAGE_RELAY_URL, state.serviceUrl)
)
);
const requestedUserId = stringValue(args['user-id'], process.env.GPT_IMAGE_USER_ID, state.userId);
const profileName = stringValue(args['profile-name'], process.env.GPT_IMAGE_PROFILE_NAME);
const session = await postRelayJson(serviceRoot, '/api/session', { userId: requestedUserId });
let user = session.user || null;
if (profileName) {
const registered = await postRelayJson(
serviceRoot,
'/api/session/register',
{ userId: user?.id || requestedUserId, profileName },
user?.id || requestedUserId
);
user = registered.user || user;
}
if (!user || !user.id) {
throw new Error('Relay did not return a usable user session');
}
if (options.save || boolValue(args['save-session'])) {
saveState(statePath, {
...state,
serviceUrl: serviceRoot,
userId: user.id,
profileName: user.profileName || profileName || state.profileName || null,
});
}
return { serviceRoot, user, statePath };
}
async function redeemPurchaseKey(args, sessionInfo) {
const purchaseKey = stringValue(args['purchase-key'], process.env.GPT_IMAGE_PURCHASE_KEY);
if (!purchaseKey) return null;
const valid = await postRelayJson(
sessionInfo.serviceRoot,
'/api/keys',
{ action: 'validate', key: purchaseKey },
sessionInfo.user.id
);
if (!valid.valid) {
throw new Error('purchase key is invalid or already used');
}
await postRelayJson(
sessionInfo.serviceRoot,
'/api/keys',
{ action: 'consume', key: purchaseKey },
sessionInfo.user.id
);
const status = await postRelayJson(
sessionInfo.serviceRoot,
'/api/keys',
{ action: 'status' },
sessionInfo.user.id
);
return status.user || null;
}
async function consumeRelayQuota(args, sessionInfo, hasInputImage) {
const quota = stringValue(args.quota, process.env.GPT_IMAGE_QUOTA_MODE) || 'auto';
if (quota === 'none') return { quota: 'none' };
if (quota === 'free') {
await postRelayJson(sessionInfo.serviceRoot, '/api/keys', { action: 'consume_free' }, sessionInfo.user.id);
return { quota: 'free' };
}
if (quota === 'credit') {
const result = await postRelayJson(sessionInfo.serviceRoot, '/api/keys', { action: 'consume_credit' }, sessionInfo.user.id);
return { quota: 'credit', credits: result.credits };
}
if (quota !== 'auto') {
throw new Error(`Unsupported quota mode: quota`);
}
if (!hasInputImage) {
const free = await postRelayJson(sessionInfo.serviceRoot, '/api/keys', { action: 'check_free' }, sessionInfo.user.id);
if (free.free) {
await postRelayJson(sessionInfo.serviceRoot, '/api/keys', { action: 'consume_free' }, sessionInfo.user.id);
return { quota: 'free' };
}
}
const result = await postRelayJson(sessionInfo.serviceRoot, '/api/keys', { action: 'consume_credit' }, sessionInfo.user.id);
return { quota: 'credit', credits: result.credits };
}
async function waitForRelayJob(root, jobId, userId, timeoutMs, pollIntervalMs) {
const startedAt = Date.now();
while (Date.now() - startedAt <= timeoutMs) {
const job = await getRelayJson(root, `/api/generate/jobs/encodeURIComponent(jobId)`, userId);
if (job.status === 'queued' || job.status === 'running') {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
continue;
}
if (job.status === 'succeeded' && job.imageUrl) {
return job;
}
throw new Error(job.error || `Relay job failed with status: job.status`);
}
throw new Error(`Relay job timed out after timeoutMsms`);
}
async function generateReserved(args) {
const prompt = requireValue('prompt', stringValue(args.prompt, process.env.PROMPT));
const inputImage = readImageDataUrl(stringValue(args.image, process.env.GPT_IMAGE_INPUT_IMAGE));
const sessionInfo = await ensureRelaySession(args, { save: boolValue(args['save-session']) });
const redeemedUser = await redeemPurchaseKey(args, sessionInfo);
if (redeemedUser) {
sessionInfo.user = redeemedUser;
}
const quota = await consumeRelayQuota(args, sessionInfo, !!inputImage);
if (quota && quota.quota === 'credit' && Number.isFinite(Number(quota.credits))) {
sessionInfo.user.credits = Number(quota.credits);
}
const preferredProviders = stringValue(args['preferred-providers'], process.env.GPT_IMAGE_PREFERRED_PROVIDERS)
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const created = await postRelayJson(
sessionInfo.serviceRoot,
'/api/generate/jobs',
{
prompt,
image: inputImage,
userId: sessionInfo.user.id,
preferredProviders,
},
sessionInfo.user.id
);
const jobId = requireValue('relay jobId', created.jobId);
const timeoutMs = Math.max(1000, integerValue(args['timeout-ms'] || process.env.GPT_IMAGE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS));
const pollIntervalMs = Math.max(250, integerValue(args['poll-interval-ms'] || process.env.GPT_IMAGE_POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS));
const job = await waitForRelayJob(sessionInfo.serviceRoot, jobId, sessionInfo.user.id, timeoutMs, pollIntervalMs);
const imageBuffer = await downloadRelayImage(sessionInfo.serviceRoot, job.imageUrl, sessionInfo.user.id);
return {
ok: true,
mode: 'reserved',
imageBuffer,
providerName: job.providerName || null,
jobId,
quota,
user: {
id: sessionInfo.user.id,
role: sessionInfo.user.role || null,
credits: Number(sessionInfo.user.credits || 0),
profileName: sessionInfo.user.profileName || null,
},
};
}
function writeOutput(outputPath, buffer) {
const absoluteOutput = path.resolve(outputPath || 'generated-image.png');
fs.mkdirSync(path.dirname(absoluteOutput), { recursive: true });
fs.writeFileSync(absoluteOutput, buffer);
return {
output: absoluteOutput,
bytes: fs.statSync(absoluteOutput).size,
};
}
function redactedSummary(summary) {
return JSON.stringify(summary, null, 2);
}
async function commandGenerate(args) {
const mode = stringValue(args.mode, process.env.GPT_IMAGE_MODE) || 'official';
let result = null;
if (mode === 'official' || mode === 'proxy') {
result = await generateDirect(args, mode);
result.imageBuffer = Buffer.from(result.imageBase64, 'base64');
delete result.imageBase64;
} else if (mode === 'reserved') {
result = await generateReserved(args);
} else {
throw new Error(`Unsupported mode: mode`);
}
const outputInfo = writeOutput(stringValue(args.output, process.env.OUTPUT) || 'generated-image.png', result.imageBuffer);
const finalCall = result.meta?.finalCall || null;
process.stdout.write(
redactedSummary({
ok: true,
mode: result.mode || mode,
providerName: result.providerName || null,
endpoint: result.endpoint || null,
jobId: result.jobId || null,
output: outputInfo.output,
bytes: outputInfo.bytes,
responseId: result.meta?.responseId || null,
image: finalCall
? {
type: finalCall.type,
model: finalCall.model || null,
quality: finalCall.quality || null,
size: finalCall.size || null,
output_format: finalCall.output_format || null,
revised_prompt: finalCall.revised_prompt || null,
}
: null,
quota: result.quota || null,
user: result.user || null,
}) + '\n'
);
}
async function commandSession(args) {
const sessionInfo = await ensureRelaySession(args, { save: boolValue(args['save-session']) });
process.stdout.write(
redactedSummary({
ok: true,
serviceUrl: sessionInfo.serviceRoot,
statePath: boolValue(args['save-session']) ? sessionInfo.statePath : null,
user: sessionInfo.user,
}) + '\n'
);
}
async function commandRedeem(args) {
requireValue('purchase-key or GPT_IMAGE_PURCHASE_KEY', stringValue(args['purchase-key'], process.env.GPT_IMAGE_PURCHASE_KEY));
const sessionInfo = await ensureRelaySession(args, { save: boolValue(args['save-session']) });
const user = await redeemPurchaseKey(args, sessionInfo);
process.stdout.write(
redactedSummary({
ok: true,
serviceUrl: sessionInfo.serviceRoot,
user: user || sessionInfo.user,
}) + '\n'
);
}
async function commandQuota(args) {
const sessionInfo = await ensureRelaySession(args, { save: false });
const status = await postRelayJson(sessionInfo.serviceRoot, '/api/keys', { action: 'status' }, sessionInfo.user.id);
const free = await postRelayJson(sessionInfo.serviceRoot, '/api/keys', { action: 'check_free' }, sessionInfo.user.id);
process.stdout.write(
redactedSummary({
ok: true,
serviceUrl: sessionInfo.serviceRoot,
free: !!free.free,
freeQuota: status.freeQuota ?? null,
user: status.user || sessionInfo.user,
}) + '\n'
);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const command = args._[0] || 'generate';
if (args.help || args.h || command === 'help') {
process.stdout.write(`help()\n`);
return;
}
if (typeof fetch !== 'function') {
throw new Error('Node 18+ is required because this script uses global fetch');
}
if (command === 'generate') {
await commandGenerate(args);
return;
}
if (command === 'session') {
await commandSession(args);
return;
}
if (command === 'redeem') {
await commandRedeem(args);
return;
}
if (command === 'quota') {
await commandQuota(args);
return;
}
throw new Error(`Unknown command: command`);
}
main().catch((error) => {
process.stderr.write(
redactedSummary({
ok: false,
error: String(error && error.message ? error.message : error),
status: error && error.status ? error.status : null,
body: error && error.body ? summarizeFailure(error.body) : null,
}) + '\n'
);
process.exit(1);
});
Free no-API-key A-share ecosystem data query skill for OpenClaw and Hermes. Use when users ask for A股、沪深京股票、指数、ETF/LOF、可转债、行业/概念板块、实时行情、K线、涨跌排行、涨停/跌停/炸板、资金流、...
---
name: freestocklineskill
description: Free no-API-key A-share ecosystem data query skill for OpenClaw and Hermes. Use when users ask for A股、沪深京股票、指数、ETF/LOF、可转债、行业/概念板块、实时行情、K线、涨跌排行、涨停/跌停/炸板、资金流、财务估值、公告、龙虎榜、大宗交易、融资融券、新闻快讯、筹码等公开数据查询,并且不希望填写 API Key、Token、Cookie 或付费账号。
version: "0.1.1"
homepage: https://github.com/Etherstrings/freeStockLIneskill#donate
metadata:
openclaw:
requires:
bins: ["python3"]
---
# freeStockLIneskill
## Payment / Donation Notice
This skill is free to install on ClawHub, but it is donation-supported.
If freeStockLIneskill helps you save time, please support ongoing use and maintenance here:
- Donate / Sponsor: <https://github.com/Etherstrings/freeStockLIneskill#donate>
- Afdian: <https://ifdian.net/a/etherstrings>
- ClawHub page: <https://clawhub.ai/etherstrings/freestocklineskill>
这是给 OpenClaw / Hermes 用的 A 股免费公开源数据查询 skill。
它的设计目标很明确:不需要用户输入任何 apikey/API Key、Token、Cookie 或付费账号;在完全免费信源下尽量覆盖较大范围的 A 股生态数据,包括股票、指数、ETF/LOF、可转债、行业/概念板块、行情/K 线、排行、涨停/跌停/炸板、资金流、财务估值、公告、龙虎榜等。
## 先执行这个
只要用户是自然语言提问,Agent 不要猜接口,直接把用户原话交给 `smart-query`:
```bash
python3 freestocklineskill/scripts/stockline_cli.py smart-query --query "贵州茅台最新价"
```
脚本会自动完成:
- 判断意图:行情、K 线、大盘、榜单、涨停、资金流、板块、财务、公告、龙虎榜、可转债等。
- 解析标的:股票名、6 位代码、`600519.SH`、`sh600519`、指数、ETF、可转债、行业/概念。
- 调用免费源:腾讯财经、新浪财经、东方财富、巨潮资讯、AKShare、efinance。
- 输出统一 JSON:成功和失败都包含 `ok`、`intent`、`normalized`、`source_chain`、`data`、`warnings`、`meta`。
不要要求用户提供 API Key、Token、Cookie、iFinD、Wind、Choice、Tushare Pro 或任何付费账号。
## 常用命令
```bash
python3 freestocklineskill/scripts/stockline_cli.py endpoint-list
python3 freestocklineskill/scripts/stockline_cli.py search-entity --query "宁德时代"
python3 freestocklineskill/scripts/stockline_cli.py quote-realtime --symbol 600519
python3 freestocklineskill/scripts/stockline_cli.py quote-history --symbol 300750 --days 30 --period daily --adjust qfq
python3 freestocklineskill/scripts/stockline_cli.py market-snapshot
python3 freestocklineskill/scripts/stockline_cli.py rank --kind amount --limit 10
python3 freestocklineskill/scripts/stockline_cli.py limit-pool --kind up --limit 30
python3 freestocklineskill/scripts/stockline_cli.py money-flow --scope market --period instant --limit 20
python3 freestocklineskill/scripts/stockline_cli.py sector --kind industry --action rank
python3 freestocklineskill/scripts/stockline_cli.py fundamental --symbol 600519 --pack all
python3 freestocklineskill/scripts/stockline_cli.py announcement --symbol 600519 --keyword 年报
python3 freestocklineskill/scripts/stockline_cli.py dragon-tiger --date 2026-04-24
python3 freestocklineskill/scripts/stockline_cli.py news --symbol 300750 --kind news --limit 10
python3 freestocklineskill/scripts/stockline_cli.py news --symbol 300750 --kind research --limit 10
python3 freestocklineskill/scripts/stockline_cli.py chip --symbol 600519 --limit 100
python3 freestocklineskill/scripts/stockline_cli.py block-trade --date 2026-04-24 --limit 50
python3 freestocklineskill/scripts/stockline_cli.py margin-trading --date 2026-04-24 --limit 100
python3 freestocklineskill/scripts/stockline_cli.py bond --action rank --limit 20
```
## Agent 规则
1. 高频自然语言先用 `smart-query`。
2. 用户明确说查某类数据时,才使用显式命令。
3. 读 [references/capability-matrix.md](references/capability-matrix.md) 判断稳定支持、best-effort、还是不要假装能做。
4. 读 [references/natural-language-routing.md](references/natural-language-routing.md) 了解关键词如何路由。
5. 读 [references/use-cases.md](references/use-cases.md) 找类似用户问法。
6. 输出给用户时必须带来源、交易日/时间戳、必要 warning。
7. `ok: false` 时不要编造数据,复述 `error.message` 和可尝试的下一步。
## 能力边界
稳定支持:
- 个股、指数、ETF、可转债实时行情
- 日/周/月/分钟 K 线
- 大盘指数与市场宽度
- A 股涨跌幅、成交额、成交量、换手率、量比、振幅、市值、PE/PB 排行
- 涨停池、跌停池、炸板池、强势股池
- 全市场主力资金流排行
- 行业/概念板块排行、成分股、个股所属板块
- 基本信息、估值、公开财务摘要、公告 PDF、龙虎榜、可转债排行
- 新闻快讯、公开研报/评级、筹码分布、大宗交易、融资融券 best-effort
Best-effort:
- 个股资金流、行业/概念资金流
- 筹码分布
- 股东、分红、研报、新闻快讯、大宗交易、融资融券
- 公开源字段会随网站变化,返回中必须保留 `warnings`
不要假装能做:
- 实时交易所 Level-2 逐笔、盘口队列
- Wind/Choice/iFinD 专属字段
- 需要登录、Cookie、Token 或付费授权的数据
- 投资建议、买卖点承诺、收益保证
## Support
- Support ongoing development: <https://github.com/Etherstrings/freeStockLIneskill#donate>
- OpenClaw / ClawHub skill page: <https://clawhub.ai/etherstrings/freestocklineskill>
### Donate
Alipay:

WeChat Pay:

FILE:agents/openai.yaml
interface:
display_name: "freeStockLIneskill"
short_description: "无 API Key 的 A 股生态免费数据查询"
default_prompt: "Use $freestocklineskill for free no-key A-share ecosystem data queries. For natural-language requests, first call `python3 freestocklineskill/scripts/stockline_cli.py smart-query --query \"<用户原话>\"`, then answer from the returned JSON with source and trade-date warnings."
policy:
allow_implicit_invocation: true
FILE:references/capability-matrix.md
# 能力矩阵
给 Agent 用,不是营销文档。先判断能力,再调用命令。
## 稳定支持
| 能力 | 入口 | 免费源 |
|---|---|---|
| 股票/指数/ETF/可转债实时行情 | `smart-query` / `quote-realtime` | 腾讯财经、AKShare |
| 日/周/月/分钟 K 线 | `smart-query` / `quote-history` | 腾讯财经、efinance |
| 大盘与市场宽度 | `smart-query` / `market-snapshot` | 腾讯财经、东方财富 |
| 涨跌幅/成交额/成交量/换手率/量比/振幅/市值/PE/PB 榜 | `smart-query` / `rank` | 东方财富、新浪 |
| 涨停池/跌停池/炸板池/强势股池 | `smart-query` / `limit-pool` | 东方财富 |
| 全市场主力资金净流入排行 | `smart-query` / `money-flow` | 新浪、AKShare |
| 行业/概念板块排行 | `smart-query` / `sector` | AKShare |
| 板块成分股 | `sector --action constituents` | AKShare |
| 个股所属板块 | `sector --action belong` | efinance |
| 公司基础信息/估值 | `smart-query` / `fundamental` | 腾讯、AKShare |
| 公告列表/PDF 链接 | `smart-query` / `announcement` | 巨潮资讯 |
| 龙虎榜 | `smart-query` / `dragon-tiger` | AKShare |
| 可转债排行/报价/K 线 | `smart-query` / `bond` | AKShare |
| 新闻快讯/公开研报/评级 | `smart-query` / `news` | AKShare/东方财富公开源 |
| 大宗交易 | `smart-query` / `block-trade` | AKShare/东方财富公开源 |
| 融资融券 | `smart-query` / `margin-trading` | AKShare/交易所公开源 |
## Best-effort
| 能力 | 入口 | 说明 |
|---|---|---|
| 个股资金流 | `money-flow --scope stock` | 公开源字段和接口稳定性随网站变化 |
| 行业/概念资金流 | `money-flow --scope industry/concept` | 返回时必须保留 warning |
| 筹码分布 | `smart-query` / `chip` | 免费源不稳定,必须保留 warning |
| 股东/分红/研报/新闻快讯 | `fundamental` / `news` | 按公开源可得字段返回 |
## 不要假装能做
- 需要登录、Cookie、Token、API Key、付费账号的数据。
- Wind、Choice、iFinD、Tushare Pro 专属字段。
- 实时逐笔成交、Level-2 盘口队列。
- 自动生成买卖建议、目标价承诺、收益保证。
- 全市场复杂组合条件选股如果现有 `rank`、`sector`、`fundamental` 无法表达,就返回当前未覆盖。
## 回答口径
- 稳定能力:直接调用并说明来源。
- Best-effort:调用,返回时说明公开源波动和 warning。
- 不支持:不要编 payload,不要编数据,直接说明当前 skill 不覆盖。
FILE:references/free-sources.md
# 免费信源
本 skill 不要求用户配置任何 API Key、Token、Cookie 或付费账号。
## 高频直连源
- 腾讯财经公开接口:实时行情、指数行情、K 线。
- 新浪财经公开接口:A 股排行、主力资金榜、名称 suggest。
- 东方财富公开接口:排行、涨停/跌停/炸板/强势股池、市场宽度。
- 巨潮资讯公开接口:公告列表、PDF 附件链接。
## 免费 Python 库
- AKShare:财务、资金流、龙虎榜、板块、可转债等广覆盖能力。
- efinance:东方财富封装,适合行情、K 线、ETF、所属板块等兜底。
- pandas:把 DataFrame 统一转成 JSON records。
## 数据使用注意
- 免费源可能延迟、限流、反爬或字段调整。
- 返回 JSON 中 `source_chain` 表示尝试过哪些源。
- 返回 JSON 中 `warnings` 表示 best-effort 或回退情况。
- 公开源返回的日期可能是最近交易日,不一定等于自然日今天。
FILE:references/natural-language-routing.md
# 自然语言路由
默认入口:
```bash
python3 freestocklineskill/scripts/stockline_cli.py smart-query --query "<用户原话>"
```
## 路由优先级
1. `可转债`、`转债` -> `bond`
2. `新闻`、`快讯`、`研报`、`评级`、`目标价` -> `news`
3. `筹码`、`筹码分布` -> `chip`
4. `大宗交易`、`大宗成交` -> `block-trade`
5. `融资融券`、`两融`、`融资余额`、`融券` -> `margin-trading`
6. `公告`、`年报`、`季报`、`业绩预告`、`PDF`、`披露` -> `announcement`
7. `龙虎榜` -> `dragon-tiger`
8. `资金流`、`主力资金`、`净流入`、`净流出` -> `money-flow`
9. `涨停`、`跌停`、`炸板`、`连板`、`封板`、`强势股` -> `limit-pool`
10. `排行`、`排名`、`榜`、`top`、`前十`、`最高`、`最低` -> `rank`
11. `板块`、`行业`、`概念` -> `sector`
12. `财务`、`基本面`、`估值`、`ROE`、`毛利率`、`市盈率`、`市净率`、`股东`、`分红` -> `fundamental`
13. `走势`、`历史`、`K线`、`日线`、`周线`、`月线`、`分钟`、`最近` -> `quote-history`
14. `大盘`、`三大指数`、`市场整体`、`涨跌家数`、多指数同问 -> `market-snapshot`
15. 默认 -> `quote-realtime`
## 常见参数解析
- `前十`、`前20`、`top 50` -> `limit`
- `近30天`、`最近一个月`、`近半年` -> `days`
- `2026-04-24`、`20260424`、`4月24日`、`今天`、`昨天` -> `date`
- `前复权` -> `qfq`
- `后复权` -> `hfq`
- `不复权` -> `none`
- `行业` -> `sector --kind industry`
- `概念` -> `sector --kind concept`
- `属于什么板块` -> `sector --action belong`
- `板块成分股` -> `sector --action constituents`
## 标的解析
优先顺序:
1. 指数别名:上证指数、深成指、创业板指、沪深300、科创50、北证50。
2. 代码:`600519`、`600519.SH`、`sh600519`。
3. 常见中文名:贵州茅台、宁德时代等内置别名。
4. 腾讯 smartbox / 新浪 suggest 免费接口。
无法稳定识别时返回 `ok: false` 和候选提示,不让 Agent 猜。
FILE:references/use-cases.md
# 常见 Use Cases
这些问法都应该优先走:
```bash
python3 freestocklineskill/scripts/stockline_cli.py smart-query --query "<用户原话>"
```
## 行情
1. 贵州茅台最新价
2. 宁德时代现在多少钱
3. 查一下 600519 行情
4. 300750 今天涨跌幅
5. 招商银行最新报价
6. 比亚迪实时行情
7. 中国平安今天开盘价
8. 五粮液最高最低价
9. 中芯国际成交额
10. 东方财富量比和换手率
11. 科创50ETF 最新价
12. 510300 现在多少钱
13. 可转债 123xxx 最新报价
14. 上证指数现在多少
15. 沪深300 今天涨跌幅
## 历史与 K 线
16. 贵州茅台近30天走势
17. 宁德时代最近一个月表现
18. 600519 近一年日线
19. 300750 近半年 K线
20. 招商银行周线走势
21. 比亚迪月线
22. 东方财富近7天走势
23. 510300 近三个月走势
24. 可转债 123xxx 近20天走势
25. 上证指数近5天走势
26. 创业板指最近一周
27. 贵州茅台后复权 K线
28. 宁德时代不复权日线
29. 600519 前复权走势
30. 科创50 近90天 K线
## 大盘与市场宽度
31. 今天大盘怎么样
32. 三大指数现在如何
33. A股市场整体表现
34. 看一下大盘
35. 今日市场涨跌家数
36. 上证深成创业板快照
37. 沪深300 和科创50 今天怎样
38. 北证50 最新表现
## 排行
39. A股涨幅榜前十
40. 今日跌幅榜前20
41. 成交额最高的股票
42. A股成交额前十
43. 成交量榜前50
44. 换手率排行
45. 量比榜前十
46. 振幅榜前二十
47. 总市值前十
48. 市盈率排行
49. 市净率最低的股票
50. 今日领涨股票
51. 今日领跌股票
52. top 30 成交额
53. A股 PE 排行
54. A股 PB 排行
## 涨停与短线情绪
55. 今日涨停
56. 今天的A股涨停数据
57. 涨停板有哪些
58. 连板股有哪些
59. 跌停池
60. 今日跌停股票
61. 炸板股有哪些
62. 强势股池
63. 2026-04-24 涨停池
64. 今天封板时间最早的股票
65. 今日涨停前50
## 资金流
66. 主力资金净流入前十
67. 今天主力资金流入排行
68. 主力资金净流出前20
69. 宁德时代资金流向
70. 贵州茅台资金流
71. 行业资金流排行
72. 概念资金流前十
73. 5日主力资金流入排行
74. 10日资金净流入前十
75. 20日资金净流出榜
## 板块
76. 行业板块涨幅排行
77. 概念板块排行
78. 今天领涨行业
79. 今天领跌概念
80. 半导体板块成分股
81. 白酒板块有哪些股票
82. 贵州茅台属于什么行业
83. 宁德时代属于什么概念
84. 新能源车概念成分股
85. AI 概念板块排行
## 基本面与估值
86. 贵州茅台基本面
87. 宁德时代估值怎么样
88. 600519 市盈率市净率
89. 招商银行 ROE
90. 比亚迪毛利率净利率
91. 东方财富资产负债率
92. 贵州茅台财务摘要
93. 宁德时代利润表
94. 招商银行现金流量表
95. 贵州茅台股东户数
96. 宁德时代十大股东
97. 贵州茅台分红记录
98. 300750 全部基本面
## 事件与资料
99. 贵州茅台公告
100. 宁德时代年报 PDF
101. 600519 业绩预告
102. 比亚迪回购公告
103. 招商银行分红公告
104. 今日龙虎榜
105. 2026-04-24 龙虎榜
106. 贵州茅台龙虎榜
107. 可转债涨幅榜前十
108. 可转债成交额排行
109. 可转债 123xxx 近30天走势
110. 新闻快讯里有没有宁德时代
111. 宁德时代新闻
112. 贵州茅台研报评级
113. 比亚迪目标价研报
114. 贵州茅台筹码分布
115. 2026-04-24 大宗交易
116. 贵州茅台大宗交易
117. 2026-04-24 融资融券
118. 宁德时代融资余额
119. 上证深成创业板快照
120. 沪深300 和科创50 今天怎样
## 散户口语和不专业问法
121. 茅台现在咋样
122. 宁王今天啥价
123. 东财现在多少
124. 招行今天咋样
125. 比王今天涨了吗
126. 中芯现在多少钱
127. 大A今天红不红
128. 今天盘面咋样
129. 两市有没有赚钱效应
130. 今天涨的多还是跌的多
131. 今天哪个票最猛
132. 今天谁跌得最惨
133. 成交最多的是谁
134. 谁最活跃
135. 换手最高的是啥
136. 便宜市盈率的票看看
137. 今天有多少涨停
138. 今天炸了哪些板
139. 连板都有哪些
140. 封单最大的票
141. 钱都往哪儿跑了
142. 主力今天买啥了
143. 主力在卖啥
144. 白酒今天强不强
145. 半导体今天咋样
146. 机器人这条线有哪些票
147. AI 这块有哪些股票
148. 茅台属于啥板块
149. 宁王是哪个行业
150. 茅台财报好不好
151. 东财估值贵不贵
152. 招行分不分红
153. 茅台有啥公告
154. 宁王最近公告
155. 有没有回购的公告
156. 今天谁上榜了
157. 茅台上榜没
158. 茅台有啥新闻
159. 宁王有没有研报
160. 茅台筹码松不松
161. 最近有没有大宗
162. 茅台有没有大宗
163. 宁王两融咋样
164. 转债今天谁最强
165. 帮我瞅眼 300750
166. 别整分析,看看大A
## 不要假装能做
- 给我保证明天涨停的股票。
- 现在还能追茅台吗。
- 宁王明天能回本吗。
- 用 Wind 字段查某只股票。
- 查询需要登录账号的逐笔成交。
- 给出确定买点和收益率。
- 不说明来源就回答一个数字。
FILE:scripts/runtime/freestocklineskill_runtime/__init__.py
"""Runtime package for freeStockLIneskill."""
__all__ = ["__version__"]
__version__ = "0.1.0"
FILE:scripts/runtime/freestocklineskill_runtime/cli.py
from __future__ import annotations
import argparse
import json
import sys
from typing import Any, Dict, List, Optional
from .endpoint_catalog import ENDPOINTS
from .envelope import failure
from .envelope import success
from .routing import Entity
from .routing import RoutePlan
from .routing import build_route_plan
from .routing import entity_search_candidates
from .routing import entity_from_symbol
from .routing import normalize_symbol
from .routing import resolve_local_entity
from .sources import SourceClient
def main(argv: Optional[List[str]] = None) -> int:
result = run_command(sys.argv[1:] if argv is None else argv)
print(json.dumps(result, ensure_ascii=False))
return 0 if result.get("ok") else 1
def run_command(argv: List[str]) -> Dict[str, Any]:
parser = build_parser()
try:
args = parser.parse_args(argv)
except SystemExit:
return failure(intent="cli", error_type="invalid_request", error_message="invalid arguments")
client = SourceClient(timeout=getattr(args, "timeout", 12.0))
try:
if args.command == "endpoint-list":
return success(intent="endpoint_list", data={"endpoints": ENDPOINTS})
if args.command == "search-entity":
return handle_search_entity(client, args.query)
if args.command == "smart-query":
return handle_smart_query(client, args.query)
if args.command == "quote-realtime":
entity = resolve_entity_for_arg(client, args.symbol, "quote_realtime")
return wrap_result("quote_realtime", args.symbol, entity.to_dict(), client.quote_realtime(entity))
if args.command == "quote-history":
entity = resolve_entity_for_arg(client, args.symbol, "quote_history")
return wrap_result(
"quote_history",
args.symbol,
entity.to_dict(),
client.quote_history(entity, days=args.days, period=args.period, adjust=args.adjust),
)
if args.command == "market-snapshot":
return wrap_result("market_snapshot", "", {}, client.market_snapshot())
if args.command == "rank":
return wrap_result("rank", "", {"kind": args.kind, "order": args.order, "limit": args.limit}, client.rank(args.kind, args.limit, args.order))
if args.command == "limit-pool":
return wrap_result(
"limit_pool",
"",
{"kind": args.kind, "date": args.date, "limit": args.limit},
client.limit_pool(args.kind, args.date, args.limit),
)
if args.command == "money-flow":
entity = resolve_entity_for_arg(client, args.symbol, "money_flow") if args.symbol else None
normalized = {"scope": args.scope, "period": args.period, "limit": args.limit}
if entity is not None:
normalized["entity"] = entity.to_dict()
return wrap_result("money_flow", args.symbol or "", normalized, client.money_flow(args.scope, args.period, entity, args.limit))
if args.command == "sector":
entity = resolve_entity_for_arg(client, args.symbol, "sector") if args.symbol else None
normalized = {"kind": args.kind, "action": args.action, "limit": args.limit}
if entity is not None:
normalized["entity"] = entity.to_dict()
return wrap_result("sector", args.symbol or "", normalized, client.sector(args.kind, args.action, entity, args.query or "", args.limit))
if args.command == "fundamental":
entity = resolve_entity_for_arg(client, args.symbol, "fundamental")
return wrap_result("fundamental", args.symbol, {"entity": entity.to_dict(), "pack": args.pack}, client.fundamental(entity, args.pack))
if args.command == "announcement":
entity = resolve_entity_for_arg(client, args.symbol, "announcement") if args.symbol else None
normalized = {"keyword": args.keyword, "limit": args.limit}
if entity is not None:
normalized["entity"] = entity.to_dict()
return wrap_result("announcement", args.symbol or "", normalized, client.announcement(entity, args.keyword, args.limit))
if args.command == "dragon-tiger":
entity = resolve_entity_for_arg(client, args.symbol, "dragon_tiger") if args.symbol else None
normalized = {"date": args.date, "limit": args.limit}
if entity is not None:
normalized["entity"] = entity.to_dict()
return wrap_result("dragon_tiger", args.symbol or "", normalized, client.dragon_tiger(args.date, entity, args.limit))
if args.command == "news":
entity = resolve_entity_for_arg(client, args.symbol, "news") if args.symbol else None
normalized = {"kind": args.kind, "keyword": args.keyword, "limit": args.limit}
if entity is not None:
normalized["entity"] = entity.to_dict()
return wrap_result("news", args.symbol or args.keyword or "", normalized, client.news(entity, args.keyword, args.kind, args.limit))
if args.command == "chip":
entity = resolve_entity_for_arg(client, args.symbol, "chip")
return wrap_result("chip", args.symbol, {"entity": entity.to_dict(), "limit": args.limit}, client.chip(entity, args.limit))
if args.command == "block-trade":
entity = resolve_entity_for_arg(client, args.symbol, "block_trade") if args.symbol else None
normalized = {"date": args.date, "limit": args.limit}
if entity is not None:
normalized["entity"] = entity.to_dict()
return wrap_result("block_trade", args.symbol or "", normalized, client.block_trade(args.date, entity, args.limit))
if args.command == "margin-trading":
entity = resolve_entity_for_arg(client, args.symbol, "margin_trading") if args.symbol else None
normalized = {"date": args.date, "limit": args.limit}
if entity is not None:
normalized["entity"] = entity.to_dict()
return wrap_result("margin_trading", args.symbol or "", normalized, client.margin_trading(args.date, entity, args.limit))
if args.command == "bond":
entity = resolve_entity_for_arg(client, args.symbol, "bond") if args.symbol else None
normalized = {"action": args.action, "limit": args.limit, "days": args.days}
if entity is not None:
normalized["entity"] = entity.to_dict()
return wrap_result("bond", args.symbol or "", normalized, client.bond(args.action, entity, args.limit, args.days))
except Exception as exc:
return failure(
intent=command_to_intent(args.command),
query=getattr(args, "query", "") or getattr(args, "symbol", "") or "",
error_type=type(exc).__name__,
error_message=str(exc),
)
return failure(intent="cli", error_type="invalid_request", error_message="unknown command")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="stockline-cli")
parser.add_argument("--timeout", type=float, default=12.0)
subparsers = parser.add_subparsers(dest="command", required=True)
p = subparsers.add_parser("smart-query")
p.add_argument("--query", required=True)
p = subparsers.add_parser("search-entity")
p.add_argument("--query", required=True)
p = subparsers.add_parser("quote-realtime")
p.add_argument("--symbol", required=True)
p = subparsers.add_parser("quote-history")
p.add_argument("--symbol", required=True)
p.add_argument("--days", type=int, default=30)
p.add_argument("--period", choices=["daily", "weekly", "monthly", "minute"], default="daily")
p.add_argument("--adjust", choices=["qfq", "hfq", "none"], default="qfq")
subparsers.add_parser("market-snapshot")
p = subparsers.add_parser("rank")
p.add_argument("--kind", choices=["gainers", "losers", "amount", "volume", "turnover", "volume-ratio", "amplitude", "market-cap", "pe", "pb"], default="gainers")
p.add_argument("--order", choices=["auto", "asc", "desc"], default="auto")
p.add_argument("--limit", type=int, default=20)
p = subparsers.add_parser("limit-pool")
p.add_argument("--kind", choices=["up", "down", "broken", "strong"], default="up")
p.add_argument("--date")
p.add_argument("--limit", type=int, default=50)
p = subparsers.add_parser("money-flow")
p.add_argument("--scope", choices=["stock", "market", "industry", "concept"], default="market")
p.add_argument("--symbol")
p.add_argument("--period", choices=["instant", "3d", "5d", "10d", "20d"], default="instant")
p.add_argument("--limit", type=int, default=20)
p = subparsers.add_parser("sector")
p.add_argument("--kind", choices=["industry", "concept"], default="industry")
p.add_argument("--action", choices=["rank", "constituents", "belong"], default="rank")
p.add_argument("--symbol")
p.add_argument("--query")
p.add_argument("--limit", type=int, default=20)
p = subparsers.add_parser("fundamental")
p.add_argument("--symbol", required=True)
p.add_argument("--pack", choices=["basic", "valuation", "financials", "holders", "dividend", "all"], default="basic")
p = subparsers.add_parser("announcement")
p.add_argument("--symbol")
p.add_argument("--keyword")
p.add_argument("--limit", type=int, default=20)
p = subparsers.add_parser("dragon-tiger")
p.add_argument("--date")
p.add_argument("--symbol")
p.add_argument("--limit", type=int, default=50)
p = subparsers.add_parser("news")
p.add_argument("--symbol")
p.add_argument("--keyword")
p.add_argument("--kind", choices=["news", "research"], default="news")
p.add_argument("--limit", type=int, default=20)
p = subparsers.add_parser("chip")
p.add_argument("--symbol", required=True)
p.add_argument("--limit", type=int, default=200)
p = subparsers.add_parser("block-trade")
p.add_argument("--date")
p.add_argument("--symbol")
p.add_argument("--limit", type=int, default=50)
p = subparsers.add_parser("margin-trading")
p.add_argument("--date")
p.add_argument("--symbol")
p.add_argument("--limit", type=int, default=100)
p = subparsers.add_parser("bond")
p.add_argument("--action", choices=["quote", "history", "rank"], default="rank")
p.add_argument("--symbol")
p.add_argument("--limit", type=int, default=20)
p.add_argument("--days", type=int, default=30)
subparsers.add_parser("endpoint-list")
return parser
def handle_search_entity(client: SourceClient, query: str) -> Dict[str, Any]:
local = resolve_local_entity(query)
if local is not None:
return success(intent="search_entity", query=query, normalized={"entity": local.to_dict()}, source_chain=[{"source": "local_alias", "ok": True}], data=local.to_dict())
resolved = search_entity_candidates(client, query)
entity = resolved["entity"]
return success(
intent="search_entity",
query=query,
normalized={"entity": entity.to_dict()},
source_chain=resolved["source_chain"],
data=entity.to_dict(),
warnings=resolved["warnings"],
)
def handle_smart_query(client: SourceClient, query: str) -> Dict[str, Any]:
plan = build_route_plan(query)
if plan.command == "unsupported":
return failure(
intent=plan.intent,
query=query,
error_type="unsupported_request",
error_message="freeStockLIneskill 只做免费公开数据查询,不做预测、荐股或收益保证",
normalized=plan.normalized(),
data={"hints": ["可以改问:某股票最新价、近一个月走势、公告、资金流、龙虎榜、板块成分股等"]},
)
may_have_entity = bool(entity_search_candidates(query))
should_search_entity = plan.command in {
"quote-realtime",
"quote-history",
"fundamental",
"money-flow",
"announcement",
"dragon-tiger",
"news",
"chip",
"block-trade",
"margin-trading",
"bond",
}
if plan.command == "sector" and plan.params.get("action") == "belong":
should_search_entity = True
if plan.command == "money-flow" and plan.params.get("scope") in {"industry", "concept"}:
should_search_entity = False
if plan.command == "bond" and plan.params.get("action") == "rank":
should_search_entity = False
if should_search_entity and plan.entity is None and may_have_entity:
try:
plan = build_route_plan(query, entity=search_entity_candidates(client, query, asset_hint="bond" if plan.intent == "bond" else None)["entity"])
except Exception:
pass
required_entity_missing = plan.command in {"quote-realtime", "quote-history", "fundamental"} and plan.entity is None
optional_entity_was_requested = may_have_entity and plan.command in {"money-flow", "announcement", "dragon-tiger", "news", "block-trade", "margin-trading", "bond"} and plan.entity is None
if plan.command == "money-flow" and plan.params.get("scope") in {"market", "industry", "concept"}:
optional_entity_was_requested = False
if plan.command == "bond" and plan.params.get("action") == "rank":
optional_entity_was_requested = False
required_entity_missing = required_entity_missing or (plan.command in {"chip"} and plan.entity is None)
if plan.command == "sector" and plan.params.get("action") == "belong" and plan.entity is None:
required_entity_missing = True
if required_entity_missing or optional_entity_was_requested:
return failure(
intent=plan.intent,
query=query,
error_type="entity_not_found",
error_message="无法从自然语言中稳定识别股票、指数、ETF 或可转债标的",
normalized=plan.normalized(),
data={"hints": ["请提供 6 位代码,如 600519", "请提供更完整的中文简称,如 贵州茅台"]},
)
try:
result = execute_plan(client, plan)
return wrap_result(plan.intent, query, plan.normalized(), result)
except Exception as exc:
return failure(
intent=plan.intent,
query=query,
error_type=type(exc).__name__,
error_message=str(exc),
normalized=plan.normalized(),
)
def search_entity_candidates(client: SourceClient, query: str, asset_hint: Optional[str] = None) -> Dict[str, Any]:
warnings: List[str] = []
tried: List[str] = []
for candidate in entity_search_candidates(query):
if candidate in tried:
continue
tried.append(candidate)
local = resolve_local_entity(candidate, asset_hint=asset_hint)
if local is not None:
return {
"entity": local,
"source_chain": [{"source": "local_alias", "ok": True, "query": candidate}],
"warnings": warnings,
}
normalized = normalize_symbol(candidate, asset_hint=asset_hint)
if normalized:
return {
"entity": entity_from_symbol(candidate, normalized, query=query),
"source_chain": [{"source": "symbol_rule", "ok": True, "query": candidate}],
"warnings": warnings,
}
try:
resolved = client.search_entity(candidate)
for item in resolved.get("source_chain", []):
if isinstance(item, dict):
item.setdefault("query", candidate)
if candidate != query:
resolved.setdefault("warnings", []).append("已从自然语言中抽取标的候选:%s" % candidate)
return resolved
except Exception as exc:
warnings.append("候选标的 %s 解析失败: %s" % (candidate, exc))
raise RuntimeError("无法解析标的:%s" % query)
def execute_plan(client: SourceClient, plan: RoutePlan) -> Dict[str, Any]:
if plan.command == "quote-realtime":
return client.quote_realtime(plan.entity) # type: ignore[arg-type]
if plan.command == "quote-history":
return client.quote_history(
plan.entity, # type: ignore[arg-type]
days=plan.params.get("days", 30),
period=plan.params.get("period", "daily"),
adjust=plan.params.get("adjust", "qfq"),
)
if plan.command == "market-snapshot":
return client.market_snapshot()
if plan.command == "rank":
return client.rank(plan.params.get("kind", "gainers"), plan.params.get("limit", 20), plan.params.get("order", "desc"))
if plan.command == "limit-pool":
return client.limit_pool(plan.params.get("kind", "up"), plan.params.get("date"), plan.params.get("limit", 50))
if plan.command == "money-flow":
return client.money_flow(plan.params.get("scope", "market"), plan.params.get("period", "instant"), plan.entity, plan.params.get("limit", 20))
if plan.command == "sector":
return client.sector(plan.params.get("kind", "industry"), plan.params.get("action", "rank"), plan.entity, plan.query, plan.params.get("limit", 20))
if plan.command == "fundamental":
return client.fundamental(plan.entity, plan.params.get("pack", "basic")) # type: ignore[arg-type]
if plan.command == "announcement":
return client.announcement(plan.entity, plan.params.get("keyword"), plan.params.get("limit", 20))
if plan.command == "dragon-tiger":
return client.dragon_tiger(plan.params.get("date"), plan.entity, plan.params.get("limit", 50))
if plan.command == "news":
return client.news(plan.entity, plan.params.get("keyword"), plan.params.get("kind", "news"), plan.params.get("limit", 20))
if plan.command == "chip":
return client.chip(plan.entity, plan.params.get("limit", 200)) # type: ignore[arg-type]
if plan.command == "block-trade":
return client.block_trade(plan.params.get("date"), plan.entity, plan.params.get("limit", 50))
if plan.command == "margin-trading":
return client.margin_trading(plan.params.get("date"), plan.entity, plan.params.get("limit", 100))
if plan.command == "bond":
return client.bond(plan.params.get("action", "rank"), plan.entity, plan.params.get("limit", 20), plan.params.get("days", 30))
raise RuntimeError("unsupported route command: %s" % plan.command)
def wrap_result(intent: str, query: str, normalized: Dict[str, Any], result: Dict[str, Any]) -> Dict[str, Any]:
return success(
intent=intent,
query=query,
normalized=normalized,
source_chain=result.get("source_chain", []),
data=result.get("data"),
warnings=result.get("warnings", []),
trade_date=_extract_trade_date(result.get("data")),
source_status=_source_status(result.get("source_chain", [])),
)
def resolve_entity_for_arg(client: SourceClient, raw: str, intent: str) -> Entity:
local = resolve_local_entity(raw, asset_hint="bond" if intent == "bond" else None)
if local is not None:
return local
normalized = normalize_symbol(raw, asset_hint="bond" if intent == "bond" else None)
if normalized:
return entity_from_symbol(raw, normalized, query="转债" if intent == "bond" else "")
return client.search_entity(raw)["entity"]
def command_to_intent(command: str) -> str:
return command.replace("-", "_")
def _source_status(chain: List[Dict[str, Any]]) -> Dict[str, Any]:
return {str(item.get("source")): bool(item.get("ok")) for item in chain}
def _extract_trade_date(data: Any) -> Any:
if isinstance(data, dict):
for key in ("trade_date", "end_date", "date"):
if data.get(key):
return data[key]
if isinstance(data.get("data"), dict):
return _extract_trade_date(data["data"])
return None
FILE:scripts/runtime/freestocklineskill_runtime/endpoint_catalog.py
from __future__ import annotations
from typing import Dict, List
ENDPOINTS: List[Dict[str, object]] = [
{
"name": "smart-query",
"description": "自然语言万能入口。Agent 首选,把用户原话放进 --query。",
"example": 'python3 freestocklineskill/scripts/stockline_cli.py smart-query --query "贵州茅台最新价"',
},
{"name": "search-entity", "description": "解析股票/指数/ETF/可转债名称或代码。"},
{"name": "quote-realtime", "description": "个股、指数、ETF、可转债实时行情。"},
{"name": "quote-history", "description": "日/周/月/分钟 K 线,支持 qfq/hfq/none。"},
{"name": "market-snapshot", "description": "主要指数与市场宽度快照。"},
{"name": "rank", "description": "涨跌幅、成交额、成交量、换手率、量比、振幅、市值、PE/PB 榜。"},
{"name": "limit-pool", "description": "涨停池、跌停池、炸板池、强势股池。"},
{"name": "money-flow", "description": "个股、全市场、行业、概念资金流。"},
{"name": "sector", "description": "行业/概念板块排行、成分股、个股所属板块。"},
{"name": "fundamental", "description": "基本信息、估值、财务报表、股东、分红。"},
{"name": "announcement", "description": "公告列表与 PDF 链接。"},
{"name": "dragon-tiger", "description": "龙虎榜。"},
{"name": "news", "description": "公开新闻、快讯、研报、评级。"},
{"name": "chip", "description": "筹码分布 best-effort。"},
{"name": "block-trade", "description": "大宗交易明细。"},
{"name": "margin-trading", "description": "融资融券明细。"},
{"name": "bond", "description": "可转债报价、K 线、排行。"},
]
FILE:scripts/runtime/freestocklineskill_runtime/envelope.py
from __future__ import annotations
from datetime import date
from datetime import datetime
from datetime import timezone
from decimal import Decimal
import math
from typing import Any, Dict, Iterable, List, Optional
def utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def success(
*,
intent: str,
query: str = "",
normalized: Optional[Dict[str, Any]] = None,
source_chain: Optional[List[Dict[str, Any]]] = None,
data: Any = None,
warnings: Optional[Iterable[str]] = None,
trade_date: Optional[str] = None,
source_status: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
return {
"ok": True,
"intent": intent,
"query": query,
"normalized": json_safe(normalized or {}),
"source_chain": json_safe(source_chain or []),
"data": json_safe(data),
"warnings": list(warnings or []),
"meta": {
"generated_at": utc_now_iso(),
"trade_date": json_safe(trade_date),
"source_status": json_safe(source_status or {}),
},
}
def failure(
*,
intent: str,
query: str = "",
error_type: str = "runtime_failed",
error_message: str,
normalized: Optional[Dict[str, Any]] = None,
source_chain: Optional[List[Dict[str, Any]]] = None,
warnings: Optional[Iterable[str]] = None,
source_status: Optional[Dict[str, Any]] = None,
data: Any = None,
) -> Dict[str, Any]:
return {
"ok": False,
"intent": intent,
"query": query,
"normalized": json_safe(normalized or {}),
"source_chain": json_safe(source_chain or []),
"data": json_safe(data),
"warnings": list(warnings or []),
"error": {
"type": error_type,
"message": error_message,
},
"meta": {
"generated_at": utc_now_iso(),
"trade_date": None,
"source_status": json_safe(source_status or {}),
},
}
def json_safe(value: Any) -> Any:
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, Decimal):
return float(value) if value.is_finite() else None
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, dict):
return {str(key): json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [json_safe(item) for item in value]
if hasattr(value, "item"):
try:
return json_safe(value.item())
except Exception:
pass
if hasattr(value, "isoformat"):
try:
return value.isoformat()
except Exception:
pass
return str(value)
FILE:scripts/runtime/freestocklineskill_runtime/routing.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from datetime import timedelta
import re
import unicodedata
from typing import Any, Dict, List, Optional, Tuple
INDEX_ALIASES = {
"上证指数": ("上证指数", "000001.SH"),
"上证综指": ("上证指数", "000001.SH"),
"沪指": ("上证指数", "000001.SH"),
"上证": ("上证指数", "000001.SH"),
"上证50": ("上证50", "000016.SH"),
"深证成指": ("深证成指", "399001.SZ"),
"深成指": ("深证成指", "399001.SZ"),
"深证": ("深证成指", "399001.SZ"),
"创业板指": ("创业板指", "399006.SZ"),
"创业板": ("创业板指", "399006.SZ"),
"沪深300": ("沪深300", "000300.SH"),
"科创50": ("科创50", "000688.SH"),
"北证50": ("北证50", "899050.BJ"),
"中证500": ("中证500", "000905.SH"),
"中证1000": ("中证1000", "000852.SH"),
"国证2000": ("国证2000", "399303.SZ"),
"中证红利指数": ("中证红利", "000922.SH"),
"中证红利": ("中证红利", "000922.SH"),
}
ETF_ALIASES = {
"沪深300ETF": ("沪深300ETF", "510300.SH"),
"300ETF": ("沪深300ETF", "510300.SH"),
"科创50ETF": ("科创50ETF", "588000.SH"),
"科创板50ETF": ("科创板50ETF", "588080.SH"),
"科创板ETF": ("科创板ETF", "588080.SH"),
"创业板ETF": ("创业板ETF", "159915.SZ"),
"创业板50ETF": ("创业板50ETF", "159949.SZ"),
"中证500ETF": ("中证500ETF", "510500.SH"),
"中证1000ETF": ("中证1000ETF", "512100.SH"),
"上证50ETF": ("上证50ETF", "510050.SH"),
}
COMMON_SYMBOLS = {
"美的集团": "000333.SZ",
"贵州茅台": "600519.SH",
"茅子": "600519.SH",
"茅台": "600519.SH",
"宁德时代": "300750.SZ",
"宁王": "300750.SZ",
"宁德": "300750.SZ",
"东方财富": "300059.SZ",
"东财": "300059.SZ",
"中国平安": "601318.SH",
"平安银行": "000001.SZ",
"招商银行": "600036.SH",
"招行": "600036.SH",
"五粮液": "000858.SZ",
"比亚迪": "002594.SZ",
"比王": "002594.SZ",
"万科A": "000002.SZ",
"万科": "000002.SZ",
"中信证券": "600030.SH",
"工商银行": "601398.SH",
"农业银行": "601288.SH",
"中国银行": "601988.SH",
"建设银行": "601939.SH",
"中芯国际": "688981.SH",
"中芯": "688981.SH",
"寒武纪": "688256.SH",
"药明康德": "603259.SH",
"迈瑞医疗": "300760.SZ",
"隆基绿能": "601012.SH",
"立讯精密": "002475.SZ",
"紫金矿业": "601899.SH",
"长江电力": "600900.SH",
"中国移动": "600941.SH",
"中移动": "600941.SH",
"海天味业": "603288.SH",
"海天": "603288.SH",
"三一重工": "600031.SH",
"三一": "600031.SH",
"牧原股份": "002714.SZ",
"牧原": "002714.SZ",
"牧原猪肉": "002714.SZ",
"韦尔股份": "603501.SH",
"韦尔": "603501.SH",
"中国船舶": "600150.SH",
"船舶": "600150.SH",
"赛力斯": "601127.SH",
"工业富联": "601138.SH",
"阳光电源": "300274.SZ",
"爱尔眼科": "300015.SZ",
"中国神华": "601088.SH",
"长城汽车": "601633.SH",
"保利发展": "600048.SH",
"中际旭创": "300308.SZ",
"新易盛": "300502.SZ",
"江淮汽车": "600418.SH",
"北方华创": "002371.SZ",
"中微公司": "688012.SH",
"中航沈飞": "600760.SH",
"歌尔股份": "002241.SZ",
"海螺水泥": "600585.SH",
}
BOARD_HINTS = {
"白酒": "industry",
"半导体": "industry",
"券商": "industry",
"证券": "industry",
"银行": "industry",
"猪肉": "concept",
"光伏": "concept",
"低空经济": "concept",
"人形机器人": "concept",
"机器人": "concept",
"AI": "concept",
"CPO": "concept",
"算力": "concept",
"新能源车": "concept",
}
CN_NUM = {
"零": 0,
"一": 1,
"二": 2,
"两": 2,
"俩": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
"十": 10,
"百": 100,
"〇": 0,
}
SYMBOL_RE = re.compile(r"(?i)(?<![a-z0-9])(?:(?:sh|sz|bj)[.\-]?)?\d{6}(?:\.(?:sh|sz|bj))?(?![a-z0-9])")
DATE_SPAN_RE = re.compile(r"(?:近|最近|过去)\s*(?:\d{1,4}|[零〇一二三四五六七八九十两俩百]+|一|半)\s*(?:天|日|周|个?星期|个月|月|年|个?交易日)")
FULL_DATE_RE = re.compile(r"20\d{2}[-/.年]?\s*\d{1,2}[-/.月]?\s*\d{1,2}日?")
CHINESE_FULL_DATE_RE = re.compile(r"[二〇零一二三四五六七八九]{4}年[一二三四五六七八九十两俩]{1,3}月[一二三四五六七八九十两俩]{1,3}[日号]?")
MONTH_DAY_RE = re.compile(r"\d{1,2}月\d{1,2}[日号]?")
ENTITY_QUERY_NOISE = [
"帮我看一下",
"帮我看看",
"帮我查一下",
"帮我查下",
"帮我看下",
"帮我瞅瞅",
"别废话",
"别整分析",
"别分析",
"不要分析",
"我不要分析",
"别给建议",
"不要建议",
"不要投资建议",
"只要数据",
"只查数据",
"只要",
"给我用免费的源",
"用免费的源",
"免费源",
"麻烦看一下",
"麻烦查一下",
"麻烦看下",
"麻烦查下",
"我想看看",
"我想查",
"给我看一下",
"看一下",
"看下",
"查一下",
"查下",
"查询",
"看看",
"瞅瞅",
"瞧瞧",
"帮我",
"麻烦",
"劳烦",
"请帮忙",
"请问",
"请",
"一下",
"今天",
"今日",
"昨天",
"明天",
"现在",
"最近",
"去年",
"全市场",
"半年K线",
"半年k线",
"半年走势",
"这只股票",
"这个票",
"这只票",
"这公司",
"这票",
"这货",
"这个债",
"这只债",
"最新价格",
"最新价",
"最新",
"现在多少钱",
"现在价格",
"现在价",
"多少钱",
"啥价",
"咋样",
"咋走的",
"咋走",
"走的",
"走得",
"啥样",
"啥情况",
"好不好",
"顶不顶",
"红不红",
"红绿",
"崩了",
"跌了没",
"强不强",
"最火",
"是不是",
"涨了吗",
"跌了吗",
"贵不贵",
"钱",
"都往哪儿跑了",
"都往哪儿跑",
"往哪儿跑",
"往哪跑",
"跑了",
"股价",
"价格",
"行情",
"开盘价",
"开盘",
"收盘价",
"收盘",
"昨收",
"最高最低",
"最高价",
"最低价",
"涨跌幅",
"涨跌额",
"涨跌",
"成交额",
"成交量",
"量比和换手率",
"换手率",
"换手",
"量比",
"走势",
"近况",
"历史走势",
"历史",
"k线",
"K线",
"kline",
"日线",
"周线",
"月线",
"周K",
"月K",
"周k",
"月k",
"分钟线",
"分钟",
"分时",
"公告",
"年报",
"半年报",
"季报",
"一季报",
"三季报",
"业绩预告",
"披露",
"PDF",
"pdf",
"龙虎榜",
"上龙虎榜了吗",
"上龙虎榜",
"了吗",
"吗",
"资金流向",
"资金流",
"资金去哪了",
"资金去哪",
"资金在进还是出",
"在进还是出",
"进还是出",
"钱流哪去了",
"钱流哪",
"钱去哪了",
"钱去哪",
"资金抱团哪里",
"资金抱团",
"主力跑路",
"跑路最多",
"资金在买",
"吸金",
"钱进没",
"还有人买",
"人买吗",
"资金",
"主力资金",
"主力",
"净流入",
"净流出",
"流入",
"流出",
"在卖",
"所属行业",
"所属概念",
"属于什么行业",
"属于什么概念",
"属于",
"所属",
"板块",
"行业",
"概念",
"成分股",
"成份股",
"成分",
"成份",
"有哪些股票",
"有哪些",
"包含",
"基本面",
"财务",
"估值",
"市盈率",
"市净率",
"毛利率",
"净利率",
"资产负债表",
"资产负债率",
"资产负债",
"利润表",
"现金流量表",
"现金流",
"股东户数",
"十大股东",
"股东",
"分红",
"派息",
"记录",
"全部",
"排行",
"排名",
"涨幅榜",
"跌幅榜",
"成交额榜",
"成交量榜",
"换手率榜",
"量比榜",
"振幅榜",
"市值榜",
"榜",
"前二十",
"前五十",
"前十",
"最高",
"最低",
"最大",
"最小",
"回购",
"减持",
"增持",
"重大事项",
"新闻",
"快讯",
"消息",
"利好",
"利空",
"有雷",
"爆雷",
"有没有",
"有啥",
"被研报提到",
"被提到",
"机构怎么看",
"机构看法",
"里",
"研报",
"评级",
"目标价",
"筹码分布",
"筹码",
"大宗交易",
"大宗成交",
"大宗",
"大宗卖出",
"上榜",
"上榜没",
"融资融券",
"两融",
"融资",
"融资余额",
"融资盘",
"余额",
"融券",
"多少",
"怎么样",
"如何",
"什么",
"啥",
"哪个",
"哪儿",
"谁",
"股票",
"A股",
"a股",
]
@dataclass(frozen=True)
class Entity:
raw: str
symbol: str
code: str
market: str
name: Optional[str]
asset_type: str
def to_dict(self) -> Dict[str, Any]:
return {
"raw": self.raw,
"symbol": self.symbol,
"code": self.code,
"market": self.market,
"name": self.name,
"asset_type": self.asset_type,
}
@dataclass(frozen=True)
class RoutePlan:
intent: str
command: str
query: str
entity: Optional[Entity]
params: Dict[str, Any]
def normalized(self) -> Dict[str, Any]:
result = {
"command": self.command,
"params": self.params,
}
if self.entity is not None:
result["entity"] = self.entity.to_dict()
return result
def normalize_symbol(text: str, prefer_index: bool = False, asset_hint: Optional[str] = None) -> Optional[str]:
raw = normalize_query_text(text).strip().upper().replace(" ", "").replace("-", "")
if not raw:
return None
if raw.startswith(("SH.", "SZ.", "BJ.")) and len(raw) == 9 and raw[3:].isdigit():
raw = "%s.%s" % (raw[3:], raw[:2])
if raw.startswith(("SH", "SZ", "BJ")) and len(raw) == 8 and raw[2:].isdigit():
raw = "%s.%s" % (raw[2:], raw[:2])
if "." in raw:
code, market = raw.split(".", 1)
market = market.upper()
if code.isdigit() and len(code) == 6 and market in {"SH", "SZ", "BJ"}:
return "%s.%s" % (code, market)
return None
if not raw.isdigit() or len(raw) != 6:
return None
market = infer_market(raw, prefer_index=prefer_index, asset_hint=asset_hint)
return "%s.%s" % (raw, market)
def infer_market(code: str, prefer_index: bool = False, asset_hint: Optional[str] = None) -> str:
if prefer_index and code in {"000001", "000300", "000688", "000905"}:
return "SH"
if prefer_index and code.startswith("399"):
return "SZ"
if prefer_index and code.startswith("899"):
return "BJ"
if asset_hint == "bond":
if code.startswith(("110", "113", "118")):
return "SH"
return "SZ"
if code.startswith(("50", "51", "52", "56", "58")):
return "SH"
if code.startswith(("15", "16", "18")):
return "SZ"
if code.startswith(("4", "8", "92")):
return "BJ"
if code.startswith(("6", "9")):
return "SH"
return "SZ"
def asset_type_for_symbol(symbol: str, query: str = "") -> str:
code, market = symbol.split(".", 1)
if "转债" in query or code.startswith(("110", "113", "118", "123", "127", "128")):
return "bond"
if any(name for name, (_, value) in INDEX_ALIASES.items() if value == symbol):
return "index"
if code.startswith(("000", "399", "899")) and market in {"SH", "SZ", "BJ"} and "股票" not in query:
if symbol in {item[1] for item in INDEX_ALIASES.values()}:
return "index"
if code.startswith(("50", "51", "52", "56", "58", "15", "16", "18")):
return "fund"
return "stock"
def entity_from_symbol(raw: str, symbol: str, name: Optional[str] = None, query: str = "") -> Entity:
code, market = symbol.split(".", 1)
return Entity(
raw=raw,
symbol=symbol,
code=code,
market=market,
name=name,
asset_type=asset_type_for_symbol(symbol, query=query),
)
def resolve_local_entity(query: str, prefer_index: bool = False, asset_hint: Optional[str] = None) -> Optional[Entity]:
text = normalize_query_text(query)
compact_text = re.sub(r"\s+", "", text)
for alias, (name, symbol) in sorted(ETF_ALIASES.items(), key=lambda item: len(item[0]), reverse=True):
if alias.lower() in text.lower() or alias.lower() in compact_text.lower():
return entity_from_symbol(alias, symbol, name=name, query=text)
for alias, (name, symbol) in sorted(INDEX_ALIASES.items(), key=lambda item: len(item[0]), reverse=True):
if alias.lower() in text.lower() or alias.lower() in compact_text.lower():
return entity_from_symbol(alias, symbol, name=name, query=text)
match = SYMBOL_RE.search(re.sub(r"\s+", "", text))
if match:
raw = match.group(0)
symbol = normalize_symbol(raw, prefer_index=prefer_index, asset_hint=asset_hint)
if symbol:
return entity_from_symbol(raw, symbol, query=text)
for name, symbol in sorted(COMMON_SYMBOLS.items(), key=lambda item: len(item[0]), reverse=True):
if name in text or name in compact_text:
return entity_from_symbol(name, symbol, name=name, query=text)
return None
def entity_search_candidates(query: str) -> List[str]:
text = normalize_query_text(query).strip()
if not text:
return []
candidates: List[str] = []
def add(value: str) -> None:
cleaned = _clean_entity_candidate(value)
if cleaned and cleaned not in candidates and not _looks_like_non_entity_query(cleaned):
candidates.append(cleaned)
symbol_match = SYMBOL_RE.search(text)
if symbol_match:
add(symbol_match.group(0))
compact = re.sub(r"[\s,,。!??!::;;、()()【】\[\]\"'“”‘’]+", "", text)
stripped = FULL_DATE_RE.sub("", compact)
stripped = CHINESE_FULL_DATE_RE.sub("", stripped)
stripped = MONTH_DAY_RE.sub("", stripped)
stripped = DATE_SPAN_RE.sub("", stripped)
for noise in ENTITY_QUERY_NOISE:
stripped = stripped.replace(noise, "")
stripped = re.sub(r"(?i)(?:pe|pb|roe|top|pdf)", "", stripped)
stripped = re.sub(r"\d{1,3}", "", stripped)
add(stripped)
if stripped == compact:
add(text)
for segment in re.findall(r"[\u4e00-\u9fffA-Za-z0-9]{2,12}", compact):
candidate = segment
candidate = FULL_DATE_RE.sub("", candidate)
candidate = CHINESE_FULL_DATE_RE.sub("", candidate)
candidate = MONTH_DAY_RE.sub("", candidate)
candidate = DATE_SPAN_RE.sub("", candidate)
for noise in ENTITY_QUERY_NOISE:
candidate = candidate.replace(noise, "")
candidate = re.sub(r"(?i)(pe|pb|roe|top|pdf)", "", candidate)
add(candidate)
return candidates[:8]
def parse_chinese_number(text: str) -> Optional[int]:
cleaned = normalize_query_text(text).strip()
if not cleaned:
return None
if cleaned.isdigit():
return int(cleaned)
if cleaned.startswith("零") and len(cleaned) > 1:
return parse_chinese_number(cleaned[1:])
if cleaned == "十":
return 10
if "百" in cleaned:
left, right = cleaned.split("百", 1)
hundreds = CN_NUM.get(left, 1) if left else 1
rest = parse_chinese_number(right) if right else 0
if rest is None:
return None
return hundreds * 100 + rest
if "十" in cleaned:
left, right = cleaned.split("十", 1)
tens = CN_NUM.get(left, 1) if left else 1
units = CN_NUM.get(right, 0) if right else 0
return tens * 10 + units
return CN_NUM.get(cleaned)
def extract_limit(query: str, default: int = 20) -> int:
query = normalize_query_text(query)
lowered = query.lower()
match = re.search(r"(?:top|前)\s*(\d{1,3})", lowered)
if match:
return max(1, min(300, int(match.group(1))))
compact = re.sub(r"\s+", "", lowered)
match = re.search(r"前\s*([一二三四五六七八九十两俩百]+)", lowered) or re.search(r"前([一二三四五六七八九十两俩百]+)", compact)
if match:
parsed = parse_chinese_number(match.group(1))
if parsed is not None:
return max(1, min(300, parsed))
if "前十" in query or "前十" in compact:
return 10
if "前二十" in query or "前二十" in compact:
return 20
if "前五十" in query or "前五十" in compact:
return 50
return default
def extract_date(query: str, today: Optional[date] = None) -> Optional[str]:
effective_today = today or date.today()
text = normalize_query_text(query)
compact_match = re.search(r"(20\d{2})(\d{2})(\d{2})", re.sub(r"\D", "", text))
if compact_match:
year, month, day = compact_match.groups()
return "%04d-%02d-%02d" % (int(year), int(month), int(day))
match = re.search(r"(20\d{2})[-/.年]?\s*(\d{1,2})[-/.月]?\s*(\d{1,2})日?", text)
if match:
year, month, day = match.groups()
return "%04d-%02d-%02d" % (int(year), int(month), int(day))
chinese = _extract_chinese_date(text)
if chinese:
return chinese
match = re.search(r"(\d{1,2})月(\d{1,2})[日号]?", text)
if match:
month, day = match.groups()
return "%04d-%02d-%02d" % (effective_today.year, int(month), int(day))
if "昨天" in text:
return (effective_today - timedelta(days=1)).isoformat()
if "今天" in text or "今日" in text:
return effective_today.isoformat()
return None
def _extract_chinese_date(text: str) -> Optional[str]:
match = re.search(r"([二〇零一二三四五六七八九]{4})年([一二三四五六七八九十两俩]{1,3})月([一二三四五六七八九十两俩]{1,3})[日号]?", text)
if not match:
return None
raw_year, raw_month, raw_day = match.groups()
year_digits = []
for char in raw_year:
digit = CN_NUM.get(char)
if digit is None or digit >= 10:
return None
year_digits.append(str(digit))
month = parse_chinese_number(raw_month)
day = parse_chinese_number(raw_day)
if month is None or day is None:
return None
return "%04d-%02d-%02d" % (int("".join(year_digits)), month, day)
def extract_days(query: str, default: int = 30) -> int:
text = normalize_query_text(query)
if any(word in text for word in ["近半个月", "最近半个月", "过去半个月", "近半月", "最近半月", "过去半月"]):
return 15
match = re.search(r"(?:近|最近|过去)\s*(\d{1,4})\s*(?:天|日|个?交易日)", text)
if match:
return max(1, min(5000, int(match.group(1))))
match = re.search(r"(?:近|最近|过去)\s*([零〇一二三四五六七八九十两俩百]+)\s*(?:天|日|个?交易日)", text)
if match:
parsed = parse_chinese_number(match.group(1))
if parsed is not None:
return max(1, min(5000, parsed))
if any(word in text for word in ["近一周", "最近一周"]):
return 7
match = re.search(r"(?:近|最近|过去)\s*(\d{1,3}|[零〇一二三四五六七八九十两俩百]+)\s*(周|个?星期|个月|月|年)", text)
if match:
raw_count, unit = match.groups()
count = int(raw_count) if raw_count.isdigit() else parse_chinese_number(raw_count)
if count is not None:
multiplier = 7 if unit in {"周", "星期", "个星期"} else 30 if unit in {"个月", "月"} else 365
return max(1, min(5000, count * multiplier))
if any(word in text for word in ["近一个月", "最近一个月"]):
return 30
if any(word in text for word in ["近三个月", "最近三个月"]):
return 90
if "近半年" in text:
return 180
if "近一年" in text:
return 365
if "半年报" not in text and ("半年" in text or "半年度" in text):
return 180
if "去年" in text or "一年走势" in text or "一年K线" in text or "一年k线" in text:
return 365
return default
def history_period(query: str) -> str:
lowered = normalize_query_text(query).lower()
if any(word in lowered for word in ["分钟", "分时", "minute", "1m", "5m", "15m", "30m", "60m"]):
return "minute"
if any(word in lowered for word in ["周k", "周线", "weekly", "week"]):
return "weekly"
if any(word in lowered for word in ["月k", "月线", "monthly"]):
return "monthly"
return "daily"
def adjust_mode(query: str) -> str:
lowered = normalize_query_text(query).lower()
if "后复权" in lowered or "hfq" in lowered:
return "hfq"
if "不复权" in lowered or "bfq" in lowered:
return "none"
return "qfq"
def rank_kind(query: str) -> str:
lowered = normalize_query_text(query).lower()
scan = lowered + re.sub(r"\s+", "", lowered)
if any(word in scan for word in ["跌幅", "领跌", "跌得", "最惨", "最狠"]):
return "losers"
if "成交额" in scan or "金额" in scan or "成交最多" in scan:
return "amount"
if "成交量" in scan:
return "volume"
if "换手" in scan or "最活跃" in scan:
return "turnover"
if "量比" in scan:
return "volume-ratio"
if "振幅" in scan:
return "amplitude"
if "市值" in scan:
return "market-cap"
if "市盈" in scan or "pe" in scan:
return "pe"
if "市净" in scan or "pb" in scan:
return "pb"
return "gainers"
def rank_order(query: str, kind: str) -> str:
lowered = normalize_query_text(query).lower()
scan = lowered + re.sub(r"\s+", "", lowered)
if kind == "losers":
return "asc"
if any(word in scan for word in ["最低", "最小", "从低到高", "低到高", "便宜", "别太高", "不太高", "lowest", "asc"]):
return "asc"
return "desc"
def money_period(query: str) -> str:
text = normalize_query_text(query)
scan = re.sub(r"(?i)top\s*\d{1,3}", "", text)
scan = re.sub(r"前\s*(?:\d{1,3}|[一二三四五六七八九十两俩百]+)", "", scan)
if re.search(r"20\s*[日天]", scan) or "二十日" in scan or "二十天" in scan:
return "20d"
if re.search(r"10\s*[日天]", scan) or "十日" in scan or "十天" in scan:
return "10d"
if re.search(r"5\s*[日天]", scan) or "五日" in scan or "五天" in scan:
return "5d"
if re.search(r"3\s*[日天]", scan) or "三日" in scan or "三天" in scan:
return "3d"
return "instant"
def generic_market_query(query: str) -> bool:
text = normalize_query_text(query)
compact = re.sub(r"\s+", "", text)
if any(alias in text or alias in compact for alias in INDEX_ALIASES):
return False
return any(
word in text or word in compact
for word in [
"大盘",
"大A",
"股市",
"盘面",
"两市",
"市场整体",
"三大指数",
"今天市场",
"市场怎么样",
"市场热不热",
"市场涨跌家数",
"涨跌家数",
"市场宽度",
"赚钱效应",
"涨的多还是跌的多",
"涨多还是跌多",
"指数们",
]
)
def major_index_mention_count(query: str) -> int:
text = normalize_query_text(query)
compact = re.sub(r"[\s++,,、/]+", "", text)
scan = text + compact
groups = [
["上证指数", "上证综指", "沪指", "上证"],
["深证成指", "深成指", "深证"],
["创业板指", "创业板"],
["沪深300"],
["科创50"],
["北证50"],
["中证500"],
["中证1000"],
["国证2000"],
["中证红利"],
]
return sum(1 for aliases in groups if any(alias in scan for alias in aliases))
def detect_intent(query: str) -> str:
query = normalize_query_text(query)
lowered = query.lower()
scan = lowered + re.sub(r"\s+", "", lowered)
if unsupported_query(query):
return "unsupported"
if generic_market_query(query) or major_index_mention_count(query) >= 2 or any(word in scan for word in ["指数快照", "大盘快照"]):
return "market_snapshot"
if any(word in scan for word in ["可转债", "转债"]) or ("债" in scan and SYMBOL_RE.search(re.sub(r"\s+", "", query))):
return "bond"
if any(word in scan for word in ["筹码", "筹码分布"]):
return "chip"
if any(word in scan for word in ["研报", "评级", "目标价", "机构怎么看", "机构看法", "机构评级"]):
return "news"
if any(word in scan for word in ["新闻", "快讯", "消息", "资讯", "利好", "利空", "有雷", "啥雷", "有没有雷", "有没有啥雷", "雷不雷", "雷没雷", "爆雷", "暴雷"]):
return "news"
if any(word in scan for word in ["大宗交易", "大宗成交", "有没有大宗", "有啥大宗", "大宗"]):
return "block_trade"
if any(word in scan for word in ["融资融券", "两融", "融资余额", "融资盘", "融券"]):
return "margin_trading"
if any(word in scan for word in ["公告", "年报", "季报", "业绩预告", "pdf", "披露", "减持", "增持", "回购"]):
return "announcement"
if "龙虎榜" in scan or "上榜" in scan:
return "dragon_tiger"
board_money_query = "资金" in scan and board_scope_hint(query) is not None
if board_money_query or any(word in scan for word in ["资金流", "主力资金", "净流入", "净流出", "钱都往哪", "钱往哪", "钱流哪", "钱去哪", "资金去哪", "资金往哪", "资金在进还是出", "资金抱团", "资金在买", "吸金", "钱进没", "还有人买", "主力买", "主力卖", "主力在买", "主力在卖", "主力跑路", "跑路"]) or ("主力" in scan and any(word in scan for word in ["买啥", "卖啥", "买什么", "卖什么"])):
return "money_flow"
if any(word in scan for word in ["涨停", "跌停", "炸板", "炸了哪些板", "连板", "封板", "封单", "强势股", "回封", "地天板", "天地板", "封得", "封死", "开板", "跌停潮"]):
return "limit_pool"
if any(word in scan for word in ["板块", "行业", "概念", "题材", "这条线", "这块"]) or _looks_like_board_chat(query):
return "sector"
has_rank_word = any(word in scan for word in ["排行", "排名", "榜", "top", "前十", "前二十", "前50", "领涨", "领跌"])
has_chat_rank_word = any(word in scan for word in ["哪个票最猛", "最猛", "最能打", "跌得最惨", "最惨", "最狠", "杀得最狠", "成交最多", "最活跃", "谁涨", "谁跌", "谁最", "换手最高", "便宜市盈率"])
has_extreme_word = any(word in scan for word in ["最高", "最低", "最大", "最小", "highest", "lowest"])
if not has_rank_word and not has_chat_rank_word and any(word in scan for word in ["开盘", "收盘", "昨收", "最高最低", "最高价", "最低价", "量比"]):
return "quote_realtime"
has_valuation_rank = (
("市盈" in scan or "市净" in scan or "pe" in scan or "pb" in scan)
and any(word in scan for word in ["最高", "最低", "最大", "最小", "highest", "lowest", "股票"])
)
if has_rank_word or has_chat_rank_word or has_valuation_rank or has_extreme_word:
return "rank"
if any(word in scan for word in ["财务", "财报", "财务底子", "基本面", "基本资料", "基本情况", "估值", "roe", "毛利率", "净利率", "资产负债", "利润表", "现金流", "股东", "分红", "派息", "每年分", "市盈率", "市净率", "便宜了", "便宜不便宜"]):
return "fundamental"
if re.search(r"\b(?:pe|pb)\b", lowered):
return "fundamental"
if DATE_SPAN_RE.search(query) or any(word in scan for word in ["走势", "历史", "k线", "kline", "日线", "周线", "月线", "周k", "月k", "分钟", "分时", "近一个月", "最近", "咋走", "走的", "走得"]):
return "quote_history"
return "quote_realtime"
def unsupported_query(query: str) -> bool:
lowered = normalize_query_text(query).lower()
compact = re.sub(r"\s+", "", lowered)
if any(word in compact for word in ["推荐股票", "推荐一只", "推荐一个", "买哪只", "买什么", "能买", "能不能买", "现在买", "值得买", "该买", "卖不卖", "买不买", "投资建议", "还能追", "能不能追", "能回本", "回本吗", "抄底吗", "能抄底"]):
return True
if any(word in compact for word in ["保证", "预测", "预判", "一定", "稳赚", "翻倍"]):
return True
if "明天" in compact and any(word in compact for word in ["涨", "跌", "涨停", "走势", "大盘", "买", "卖", "回本"]):
return True
if any(word in compact for word in ["会不会涨", "会不会跌", "能不能涨", "能不能跌", "会不会反弹", "能不能反弹", "反弹吗", "未来会涨", "未来会跌", "下周会涨", "下周会跌"]):
return True
return False
def limit_kind(query: str) -> str:
lowered = (query or "").lower()
scan = lowered + re.sub(r"\s+", "", lowered)
if "跌停" in scan:
return "down"
if "强势" in scan or "回封" in scan or "地天板" in scan:
return "strong"
if "炸板" in scan or "炸了" in scan or "开板" in scan:
return "broken"
return "up"
def sector_kind(query: str) -> str:
text = normalize_query_text(query)
compact = re.sub(r"\s+", "", text)
if "概念" in text or "概念" in compact:
return "concept"
for board, kind in BOARD_HINTS.items():
if board.lower() in text.lower() or board.lower() in compact.lower():
return kind
return "industry"
def board_scope_hint(query: str) -> Optional[str]:
text = normalize_query_text(query)
compact = re.sub(r"\s+", "", text)
for board, kind in BOARD_HINTS.items():
if board.lower() in text.lower() or board.lower() in compact.lower():
return kind
return None
def sector_action(query: str, has_entity: bool) -> str:
query = normalize_query_text(query)
scan = query + re.sub(r"\s+", "", query)
if has_entity or any(word in scan for word in ["属于", "所属", "哪个行业", "啥板块", "是哪个行业"]):
return "belong"
if any(word in scan for word in ["成分", "成份", "包含", "有哪些股票", "有哪些", "有哪些票", "有啥票", "有啥股票", "什么票", "哪些股票", "哪些票", "都有谁", "都有啥"]):
return "constituents"
return "rank"
def fundamental_pack(query: str) -> str:
lowered = normalize_query_text(query).lower()
scan = lowered + re.sub(r"\s+", "", lowered)
if any(word in scan for word in ["股东", "十大股东", "户数"]):
return "holders"
if "分红" in scan or "派息" in scan or "分不分红" in scan or "每年分" in scan:
return "dividend"
if any(word in scan for word in ["财务", "财务底子", "资产负债", "利润表", "现金流", "财报", "roe", "毛利率", "净利率", "资产负债率"]):
return "financials"
if any(word in scan for word in ["估值", "贵不贵", "便宜了", "便宜不便宜", "市盈", "市净", "pe", "pb", "市值"]):
return "valuation"
if "全部" in scan or "完整" in scan:
return "all"
return "basic"
def build_route_plan(query: str, entity: Optional[Entity] = None, today: Optional[date] = None) -> RoutePlan:
query = normalize_query_text(query)
intent = detect_intent(query)
resolved = entity or resolve_local_entity(
query,
prefer_index=intent in {"market_snapshot", "quote_history"},
asset_hint="bond" if intent == "bond" else None,
)
if intent == "quote_realtime":
return RoutePlan(intent, "quote-realtime", query, resolved, {})
if intent == "quote_history":
return RoutePlan(
intent,
"quote-history",
query,
resolved,
{"days": extract_days(query), "period": history_period(query), "adjust": adjust_mode(query), "date": extract_date(query, today)},
)
if intent == "market_snapshot":
return RoutePlan(intent, "market-snapshot", query, None, {})
if intent == "rank":
kind = rank_kind(query)
return RoutePlan(intent, "rank", query, None, {"kind": kind, "order": rank_order(query, kind), "limit": extract_limit(query)})
if intent == "limit_pool":
return RoutePlan(intent, "limit-pool", query, None, {"kind": limit_kind(query), "date": extract_date(query, today), "limit": extract_limit(query, 50)})
if intent == "money_flow":
scope = "stock" if resolved is not None else "market"
compact = re.sub(r"\s+", "", query)
if "行业" in query or "行业" in compact:
scope = "industry"
if "概念" in query or "概念" in compact:
scope = "concept"
board_scope = board_scope_hint(query)
if resolved is None and board_scope is not None:
scope = board_scope
return RoutePlan(intent, "money-flow", query, resolved, {"scope": scope, "period": money_period(query), "limit": extract_limit(query)})
if intent == "sector":
return RoutePlan(intent, "sector", query, resolved, {"kind": sector_kind(query), "action": sector_action(query, resolved is not None), "limit": extract_limit(query, 20)})
if intent == "fundamental":
return RoutePlan(intent, "fundamental", query, resolved, {"pack": fundamental_pack(query)})
if intent == "announcement":
return RoutePlan(intent, "announcement", query, resolved, {"keyword": announcement_keyword(query), "limit": extract_limit(query, 20), "date": extract_date(query, today)})
if intent == "dragon_tiger":
return RoutePlan(intent, "dragon-tiger", query, resolved, {"date": extract_date(query, today), "limit": extract_limit(query, 50)})
if intent == "news":
return RoutePlan(intent, "news", query, resolved, {"kind": news_kind(query), "keyword": news_keyword(query), "limit": extract_limit(query, 20)})
if intent == "chip":
return RoutePlan(intent, "chip", query, resolved, {"limit": extract_limit(query, 200)})
if intent == "block_trade":
return RoutePlan(intent, "block-trade", query, resolved, {"date": extract_date(query, today), "limit": extract_limit(query, 50)})
if intent == "margin_trading":
return RoutePlan(intent, "margin-trading", query, resolved, {"date": extract_date(query, today), "limit": extract_limit(query, 100)})
if intent == "bond":
action = "rank"
bond_scan = query.lower() + re.sub(r"\s+", "", query.lower())
if resolved is not None and (DATE_SPAN_RE.search(query) or any(word in bond_scan for word in ["走势", "历史", "k线", "kline", "日线", "最近", "咋走", "走的", "走得"])):
action = "history"
elif resolved is not None:
action = "quote"
return RoutePlan(intent, "bond", query, resolved, {"action": action, "limit": extract_limit(query), "days": extract_days(query)})
if intent == "unsupported":
return RoutePlan(intent, "unsupported", query, resolved, {})
return RoutePlan("unknown", "smart-query", query, resolved, {})
def announcement_keyword(query: str) -> Optional[str]:
query = normalize_query_text(query)
scan = query + re.sub(r"\s+", "", query)
for word in ["年报", "半年报", "季报", "一季报", "三季报", "业绩预告", "分红", "减持", "增持", "回购", "重大事项"]:
if word in scan:
return word
return None
def news_kind(query: str) -> str:
lowered = normalize_query_text(query).lower()
scan = lowered + re.sub(r"\s+", "", lowered)
if any(word in scan for word in ["研报", "评级", "目标价", "机构怎么看", "机构看法", "机构评级"]):
return "research"
return "news"
def news_keyword(query: str) -> Optional[str]:
query = normalize_query_text(query)
scan = query + re.sub(r"\s+", "", query)
if any(word in scan for word in ["机构评级", "机构怎么看", "机构看法"]):
return "研报"
if any(word in scan for word in ["消息", "资讯", "利好", "利空", "有雷", "爆雷"]):
return None
for word in ["研报", "评级", "目标价", "新闻", "快讯"]:
if word in scan:
return word
return None
def _looks_like_board_chat(query: str) -> bool:
text = normalize_query_text(query)
compact = re.sub(r"\s+", "", text)
if not any(board.lower() in compact.lower() for board in BOARD_HINTS):
return False
explicit_board = any(word in compact for word in ["板块", "行业", "概念", "题材", "这条线", "这块", "这个方向", "方向"])
if not explicit_board and resolve_local_entity(query) is not None:
return False
return any(word in compact for word in ["强不强", "最强", "哪个最强", "咋样", "怎么样", "有动静", "动静没", "拉了吗", "趴着", "起来了", "谁在涨", "有哪些", "有哪些票", "有啥票", "什么票", "哪些票", "都有谁", "这条线", "这块", "这个方向", "方向", "题材"])
def _clean_entity_candidate(value: str) -> str:
cleaned = normalize_query_text(value).strip()
cleaned = re.sub(r"^[的了呢吧啊呀嘛吗]+|[的了呢吧啊呀嘛吗]+$", "", cleaned)
cleaned = re.sub(r"[\s,,。!??!::;;、()()【】\[\]\"'“”‘’]+", "", cleaned)
cleaned = re.sub(r"^[和与及]+|[和与及]+$", "", cleaned)
return cleaned
def normalize_query_text(text: str) -> str:
normalized = unicodedata.normalize("NFKC", text or "")
normalized = normalized.replace("\u200b", "").replace("\ufeff", "")
normalized = normalized.replace("﹣", "-").replace("-", "-")
return normalized
def _looks_like_non_entity_query(value: str) -> bool:
if len(value) < 2:
return True
lowered = value.lower()
if lowered in {"pe", "pb", "roe", "top"}:
return True
if value.isdigit() and len(value) != 6:
return True
if re.fullmatch(r"前?\d{1,3}", value):
return True
if re.fullmatch(r"前?[一二三四五六七八九十两俩百]+条?", value):
return True
if "年" in value and "月" in value and re.fullmatch(r"[二〇零一二三四五六七八九十两俩年月日号]+", value):
return True
if re.fullmatch(r"\d{0,3}日(?:入|出|资金)?", value):
return True
if re.fullmatch(r"[一二三四五六七八九十两俩百]+日(?:入|出|资金)?", value):
return True
generic_words = [
"大盘",
"市场",
"指数",
"涨停",
"跌停",
"炸板",
"排行",
"排名",
"主力资金",
"资金流",
"资金",
"流入",
"流出",
"成交额",
"成交量",
"可转债",
"转债",
"行业",
"概念",
"板块",
"成分股",
"成分",
"有哪些股票",
"有哪些",
"包含",
"最低",
"最高",
"最大",
"最小",
"回购",
"减持",
"增持",
"重大事项",
"新闻",
"快讯",
"有没有",
"里",
"研报",
"评级",
"目标价",
"筹码",
"筹码分布",
"大宗交易",
"大宗成交",
"大宗",
"融资融券",
"两融",
"融资",
"融资余额",
"融券",
"全市场",
"余额",
"最火",
"现在",
"这票",
"这个票",
"这只票",
"这公司",
"有啥",
"最近",
"都往跑",
"往跑",
"在卖",
"记录",
"全部",
"资产负债率",
]
return value in generic_words
FILE:scripts/runtime/freestocklineskill_runtime/sources.py
from __future__ import annotations
from contextlib import redirect_stderr
from contextlib import redirect_stdout
from datetime import date
import io
import json
import os
import re
import warnings
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL.*")
import requests
from .routing import Entity
from .routing import entity_from_symbol
from .routing import normalize_symbol
USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
)
EASTMONEY_UT = "7eea3edcaed734bea9cbfc24409ed989"
A_STOCK_FS = "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23"
class SourceError(RuntimeError):
pass
class SourceClient:
def __init__(self, timeout: float = 12.0) -> None:
self.timeout = timeout
self.session = requests.Session()
self.session.trust_env = False
self.session.headers.update(
{
"User-Agent": USER_AGENT,
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Connection": "close",
}
)
def _get(self, url: str, params: Optional[Dict[str, Any]] = None, referer: Optional[str] = None) -> requests.Response:
headers = {"Referer": referer} if referer else None
response = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
response.raise_for_status()
return response
def _post(self, url: str, data: Optional[Dict[str, Any]] = None, referer: Optional[str] = None) -> requests.Response:
headers = {"Referer": referer} if referer else None
response = self.session.post(url, data=data, headers=headers, timeout=self.timeout)
response.raise_for_status()
return response
def _json_get(self, url: str, params: Optional[Dict[str, Any]] = None, referer: Optional[str] = None) -> Dict[str, Any]:
response = self._get(url, params=params, referer=referer)
payload = response.json()
if not isinstance(payload, dict):
raise SourceError("response JSON is not an object")
return payload
def search_entity(self, query: str) -> Dict[str, Any]:
chain: List[Dict[str, Any]] = []
warnings: List[str] = []
for name, func in [
("tencent_smartbox", self._search_tencent),
("sina_suggest", self._search_sina),
]:
try:
entity = func(query)
chain.append({"source": name, "ok": entity is not None})
if entity is not None:
return {"entity": entity, "source_chain": chain, "warnings": warnings}
except Exception as exc:
chain.append({"source": name, "ok": False, "error": str(exc)})
warnings.append("%s 解析失败: %s" % (name, exc))
raise SourceError("无法解析标的:%s" % query)
def _search_tencent(self, query: str) -> Optional[Entity]:
response = self._get("https://smartbox.gtimg.cn/s3/", {"q": query, "t": "all"}, referer="https://gu.qq.com/")
text = _decode_response(response)
match = re.search(r'v_hint="(.*)"', text, flags=re.S)
if not match:
return None
for row in match.group(1).split("^"):
parts = row.split("~")
if len(parts) < 5:
continue
market, code, name, _, kind = parts[:5]
symbol = normalize_symbol("%s.%s" % (code, market))
if symbol:
return entity_from_symbol(query, symbol, name=_json_unescape(name), query=query if kind != "ZS" else "指数")
return None
def _search_sina(self, query: str) -> Optional[Entity]:
response = self._get(
"https://suggest3.sinajs.cn/suggest/type=11,12,13,14,15",
{"key": query, "name": "suggestdata"},
referer="https://finance.sina.com.cn/",
)
text = _decode_response(response)
match = re.search(r'"(.*)"', text, flags=re.S)
if not match:
return None
for row in match.group(1).split(";"):
parts = row.split(",")
if len(parts) < 4:
continue
name, code, provider_symbol = parts[0].strip(), parts[2].strip(), parts[3].strip().lower()
symbol = normalize_symbol(provider_symbol)
if symbol:
return entity_from_symbol(query, symbol, name=name, query=query)
if code:
symbol = normalize_symbol(code)
if symbol:
return entity_from_symbol(query, symbol, name=name, query=query)
return None
def quote_realtime(self, entity: Entity) -> Dict[str, Any]:
chain: List[Dict[str, Any]] = []
try:
data = self._quote_tencent([entity.symbol])
chain.append({"source": "tencent_finance", "ok": True})
return {"data": data[0], "source_chain": chain, "warnings": []}
except Exception as exc:
chain.append({"source": "tencent_finance", "ok": False, "error": str(exc)})
try:
rows = self._akshare_call("stock_zh_a_spot_em")
item = _find_row(rows, entity.code)
if item:
chain.append({"source": "akshare.stock_zh_a_spot_em", "ok": True})
return {"data": _normalize_akshare_quote(item, entity), "source_chain": chain, "warnings": []}
raise SourceError("akshare did not return target code")
except Exception as exc:
chain.append({"source": "akshare.stock_zh_a_spot_em", "ok": False, "error": str(exc)})
raise SourceError("实时行情所有免费源失败")
def _quote_tencent(self, symbols: Iterable[str]) -> List[Dict[str, Any]]:
provider_symbols = [to_tencent_symbol(symbol) for symbol in symbols]
response = self._get("https://qt.gtimg.cn/q=" + ",".join(provider_symbols), referer="https://gu.qq.com/")
text = _decode_response(response)
quotes: List[Dict[str, Any]] = []
for match in re.finditer(r"v_([a-z]{2}\d{6})=\"([^\"]*)\";", text):
provider_symbol = match.group(1)
fields = match.group(2).split("~")
if len(fields) < 34 or not fields[1]:
continue
quotes.append(_parse_tencent_quote(provider_symbol, fields))
if not quotes:
raise SourceError("腾讯行情返回为空")
return quotes
def quote_history(self, entity: Entity, *, days: int, period: str, adjust: str) -> Dict[str, Any]:
chain: List[Dict[str, Any]] = []
try:
data = self._history_tencent(entity.symbol, days=days, period=period, adjust=adjust)
chain.append({"source": "tencent_fqkline", "ok": True})
return {"data": data, "source_chain": chain, "warnings": []}
except Exception as exc:
chain.append({"source": "tencent_fqkline", "ok": False, "error": str(exc)})
try:
data = self._history_efinance(entity, days=days)
chain.append({"source": "efinance.get_quote_history", "ok": True})
return {"data": data, "source_chain": chain, "warnings": ["已从腾讯 K 线回退到 efinance"]}
except Exception as exc:
chain.append({"source": "efinance.get_quote_history", "ok": False, "error": str(exc)})
if period == "minute":
return {
"data": {"symbol": entity.symbol, "period": period, "adjust": adjust, "count": 0, "candles": []},
"source_chain": chain,
"warnings": ["分钟 K 线公开源暂时不可用,已返回空列表和失败详情"],
}
raise SourceError("历史行情所有免费源失败")
def _history_tencent(self, symbol: str, *, days: int, period: str, adjust: str) -> Dict[str, Any]:
provider_symbol = to_tencent_symbol(symbol)
period_key = {"daily": "day", "weekly": "week", "monthly": "month", "minute": "mline"}.get(period, "day")
adjust_key = {"qfq": "qfq", "hfq": "hfq", "none": ""}.get(adjust, "qfq")
if period_key == "mline":
param = "%s,m1,,,%d" % (provider_symbol, max(1, min(days, 240)))
else:
param = "%s,%s,,,%d,%s" % (provider_symbol, period_key, max(1, min(days, 5000)), adjust_key)
payload = self._json_get(
"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get",
{"param": param},
referer="https://gu.qq.com/",
)
node = payload.get("data", {}).get(provider_symbol, {})
rows = node.get("%s%s" % (adjust_key, period_key)) or node.get(period_key) or node.get("data") or []
candles = [_parse_kline_row(row) for row in rows if isinstance(row, list) and len(row) >= 6]
if not candles:
raise SourceError("腾讯 K 线为空")
qt = node.get("qt", {}).get(provider_symbol)
quote = _parse_tencent_quote(provider_symbol, qt) if isinstance(qt, list) else None
return {
"symbol": symbol,
"period": period,
"adjust": adjust,
"count": len(candles),
"start_date": candles[0].get("date"),
"end_date": candles[-1].get("date"),
"latest_quote": quote,
"candles": candles,
}
def _history_efinance(self, entity: Entity, *, days: int) -> Dict[str, Any]:
import efinance as ef
df = _quiet_call(ef.stock.get_quote_history, stock_codes=entity.code)
rows = _df_to_records(df)[-days:]
candles = [
{
"date": _pick(row, ["日期", "date"]),
"open": _to_float(_pick(row, ["开盘", "open"])),
"close": _to_float(_pick(row, ["收盘", "close"])),
"high": _to_float(_pick(row, ["最高", "high"])),
"low": _to_float(_pick(row, ["最低", "low"])),
"volume": _to_float(_pick(row, ["成交量", "volume"])),
"amount": _to_float(_pick(row, ["成交额", "amount"])),
}
for row in rows
]
return {"symbol": entity.symbol, "period": "daily", "adjust": "provider_default", "count": len(candles), "candles": candles}
def market_snapshot(self) -> Dict[str, Any]:
symbols = ["000001.SH", "399001.SZ", "399006.SZ", "000300.SH", "000688.SH", "899050.BJ"]
chain: List[Dict[str, Any]] = []
data = {"indices": [], "breadth": None}
warnings: List[str] = []
try:
data["indices"] = self._quote_tencent(symbols)
chain.append({"source": "tencent_finance", "ok": True})
except Exception as exc:
chain.append({"source": "tencent_finance", "ok": False, "error": str(exc)})
warnings.append("主要指数获取失败: %s" % exc)
try:
data["breadth"] = self._market_breadth_eastmoney()
chain.append({"source": "eastmoney_market_breadth", "ok": True})
except Exception as exc:
chain.append({"source": "eastmoney_market_breadth", "ok": False, "error": str(exc)})
warnings.append("市场宽度获取失败: %s" % exc)
if not data["indices"] and not data["breadth"]:
raise SourceError("大盘快照所有免费源失败")
return {"data": data, "source_chain": chain, "warnings": warnings}
def _market_breadth_eastmoney(self) -> Dict[str, Any]:
payload = self._json_get(
"https://push2.eastmoney.com/api/qt/ulist.np/get",
{
"fltt": 2,
"fields": "f104,f105,f106,f3,f6",
"secids": "1.000001,0.399001,0.399006",
},
referer="https://quote.eastmoney.com/",
)
rows = payload.get("data", {}).get("diff") or []
up = sum(_to_int(row.get("f104")) or 0 for row in rows if isinstance(row, dict))
down = sum(_to_int(row.get("f105")) or 0 for row in rows if isinstance(row, dict))
flat = sum(_to_int(row.get("f106")) or 0 for row in rows if isinstance(row, dict))
return {"up_count": up, "down_count": down, "flat_count": flat}
def rank(self, kind: str, limit: int, order: str = "desc") -> Dict[str, Any]:
if order == "auto":
order = "asc" if kind == "losers" else "desc"
chain: List[Dict[str, Any]] = []
try:
data = self._rank_eastmoney(kind, limit, order)
chain.append({"source": "eastmoney_clist", "ok": True})
return {"data": data, "source_chain": chain, "warnings": []}
except Exception as exc:
chain.append({"source": "eastmoney_clist", "ok": False, "error": str(exc)})
try:
data = self._rank_sina(kind, limit, order)
chain.append({"source": "sina_market_center", "ok": True})
return {"data": data, "source_chain": chain, "warnings": ["已从东方财富排行回退到新浪公开接口"]}
except Exception as exc:
chain.append({"source": "sina_market_center", "ok": False, "error": str(exc)})
raise SourceError("榜单所有免费源失败")
def _rank_eastmoney(self, kind: str, limit: int, order: str = "desc") -> Dict[str, Any]:
config = {
"gainers": "f3",
"losers": "f3",
"amount": "f6",
"volume": "f5",
"turnover": "f8",
"volume-ratio": "f10",
"amplitude": "f7",
"market-cap": "f20",
"pe": "f9",
"pb": "f23",
}.get(kind)
if config is None:
raise SourceError("unsupported rank kind: %s" % kind)
fid = config
po = 0 if order == "asc" else 1
params = {
"pn": 1,
"pz": max(1, min(limit, 200)),
"po": po,
"np": 1,
"ut": EASTMONEY_UT,
"fltt": 2,
"invt": 2,
"fid": fid,
"fs": A_STOCK_FS,
"fields": "f12,f13,f14,f2,f3,f4,f5,f6,f7,f8,f9,f10,f15,f16,f17,f18,f20,f21,f23",
}
payload = None
last_error: Optional[Exception] = None
for url in [
"https://push2.eastmoney.com/api/qt/clist/get",
"https://push2delay.eastmoney.com/api/qt/clist/get",
]:
try:
payload = self._json_get(url, params, referer="https://quote.eastmoney.com/")
break
except Exception as exc:
last_error = exc
if payload is None:
raise SourceError("东方财富排行失败: %s" % last_error)
rows = payload.get("data", {}).get("diff") or []
items = [_parse_eastmoney_rank_row(row, index + 1) for index, row in enumerate(rows) if isinstance(row, dict)]
if not items:
raise SourceError("东方财富排行为空")
return {"kind": kind, "order": order, "items": items, "returned_count": len(items), "total_count": payload.get("data", {}).get("total")}
def _rank_sina(self, kind: str, limit: int, order: str = "desc") -> Dict[str, Any]:
mapping = {
"gainers": "changepercent",
"losers": "changepercent",
"amount": "amount",
"volume": "volume",
"turnover": "turnoverratio",
"market-cap": "mktcap",
"pe": "per",
"pb": "pb",
}
if kind not in mapping:
raise SourceError("sina does not support rank kind: %s" % kind)
sort = mapping[kind]
asc = 1 if order == "asc" else 0
response = self._get(
"https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData",
{"page": 1, "num": max(1, min(limit, 100)), "sort": sort, "asc": asc, "node": "hs_a", "symbol": "", "_s_r_a": "page"},
referer="https://finance.sina.com.cn/",
)
rows = json.loads(_decode_response(response))
items = [_parse_sina_rank_row(row, index + 1) for index, row in enumerate(rows) if isinstance(row, dict)]
if not items:
raise SourceError("新浪排行为空")
return {"kind": kind, "order": order, "items": items, "returned_count": len(items)}
def limit_pool(self, kind: str, query_date: Optional[str], limit: int) -> Dict[str, Any]:
endpoint = {"up": "getTopicZTPool", "down": "getTopicDTPool", "broken": "getTopicZBPool", "strong": "getTopicQSPool"}.get(kind, "getTopicZTPool")
sort = "fbt:asc" if kind in {"up", "down", "broken"} else "zdp:desc"
date_text = (query_date or date.today().isoformat()).replace("-", "")
payload = self._json_get(
"https://push2ex.eastmoney.com/%s" % endpoint,
{"ut": EASTMONEY_UT, "dpt": "wz.ztzt", "Pageindex": 0, "pagesize": max(1, min(limit, 500)), "sort": sort, "date": date_text},
referer="https://quote.eastmoney.com/ztb/detail",
)
data = payload.get("data") or {}
pool = data.get("pool") or []
items = [_parse_pool_row(row, index + 1) for index, row in enumerate(pool) if isinstance(row, dict)]
return {
"data": {"kind": kind, "query_date": date_text, "trade_date": str(data.get("qdate") or ""), "total_count": data.get("tc"), "items": items},
"source_chain": [{"source": "eastmoney_%s" % endpoint, "ok": True}],
"warnings": [] if items else ["公开源返回空池,可能是非交易日或接口短暂不可用"],
}
def money_flow(self, scope: str, period: str, entity: Optional[Entity], limit: int) -> Dict[str, Any]:
chain: List[Dict[str, Any]] = []
if scope == "market":
try:
data = self._money_flow_sina_market(limit)
chain.append({"source": "sina_moneyflow", "ok": True})
return {"data": data, "source_chain": chain, "warnings": []}
except Exception as exc:
chain.append({"source": "sina_moneyflow", "ok": False, "error": str(exc)})
if scope == "stock":
try:
if entity is None:
raise SourceError("个股资金流需要 symbol")
data = self._money_flow_eastmoney_stock(entity, period, limit)
chain.append({"source": "eastmoney_stock_moneyflow", "ok": True})
return {"data": data, "source_chain": chain, "warnings": []}
except Exception as exc:
chain.append({"source": "eastmoney_stock_moneyflow", "ok": False, "error": str(exc)})
try:
data = self._money_flow_akshare(scope, period, entity, limit)
chain.append({"source": "akshare_moneyflow", "ok": True})
return {"data": data, "source_chain": chain, "warnings": ["资金流为公开源 best-effort 数据"]}
except Exception as exc:
chain.append({"source": "akshare_moneyflow", "ok": False, "error": str(exc)})
try:
if scope == "stock":
data = self._money_flow_ths(scope, period, limit, entity)
else:
data = self._money_flow_ths(scope, period, limit)
chain.append({"source": "akshare_ths_moneyflow", "ok": True})
return {"data": data, "source_chain": chain, "warnings": ["已从东方财富资金流回退到同花顺公开源"]}
except Exception as exc:
chain.append({"source": "akshare_ths_moneyflow", "ok": False, "error": str(exc)})
if scope == "stock":
return {
"data": {"scope": scope, "symbol": entity.symbol if entity else None, "period": period, "items": [], "returned_count": 0},
"source_chain": chain,
"warnings": ["个股资金流公开源暂时不可用,已返回空列表和失败详情"],
}
raise SourceError("资金流公开源失败")
def _money_flow_sina_market(self, limit: int) -> Dict[str, Any]:
response = self._get(
"https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/MoneyFlow.ssl_bkzj_ssggzj",
{"page": 1, "num": max(1, min(limit, 100)), "sort": "netamount", "asc": 0},
referer="https://finance.sina.com.cn/",
)
rows = json.loads(_decode_response(response))
return {"scope": "market", "period": "instant", "items": [_parse_sina_money_row(row, i + 1) for i, row in enumerate(rows) if isinstance(row, dict)]}
def _money_flow_eastmoney_stock(self, entity: Entity, period: str, limit: int) -> Dict[str, Any]:
secid = "%s.%s" % (1 if entity.market == "SH" else 0, entity.code)
try:
payload = self._json_get(
"https://push2his.eastmoney.com/api/qt/stock/fflow/kline/get",
{
"lmt": 0,
"klt": 101,
"secid": secid,
"fields1": "f1,f2,f3,f7",
"fields2": "f51,f52,f53,f54,f55,f56",
"ut": "b2884a393a59ad64002292a3e90d46a5",
},
referer="https://quote.eastmoney.com/",
)
source_data = payload.get("data") or {}
rows = [_parse_eastmoney_stock_money_flow_line(line) for line in source_data.get("klines") or []]
rows = [row for row in rows if row]
if not rows:
raise SourceError("东方财富个股资金流为空")
lookback = {"instant": 1, "3d": 3, "5d": 5, "10d": 10, "20d": 20}.get(period, max(1, min(limit, 120)))
selected = rows[-lookback:]
summary = {
"main_net_inflow": sum(_to_float(row.get("main_net_inflow")) or 0 for row in selected),
"small_net_inflow": sum(_to_float(row.get("small_net_inflow")) or 0 for row in selected),
"medium_net_inflow": sum(_to_float(row.get("medium_net_inflow")) or 0 for row in selected),
"large_net_inflow": sum(_to_float(row.get("large_net_inflow")) or 0 for row in selected),
"super_large_net_inflow": sum(_to_float(row.get("super_large_net_inflow")) or 0 for row in selected),
}
return {
"scope": "stock",
"symbol": entity.symbol,
"name": source_data.get("name") or entity.name,
"period": period,
"items": selected,
"returned_count": len(selected),
"summary": summary,
}
except Exception as exc:
if period == "20d":
raise
return self._money_flow_eastmoney_stock_rank(entity, period, limit, str(exc))
def _money_flow_eastmoney_stock_rank(self, entity: Entity, period: str, limit: int, fallback_reason: str) -> Dict[str, Any]:
config = {
"instant": ("f62", "f12,f14,f2,f3,f62,f184,f66,f69,f72,f75,f78,f81,f84,f87,f204,f205,f124", "今日"),
"3d": ("f267", "f12,f14,f2,f127,f267,f268,f269,f270,f271,f272,f273,f274,f275,f276,f257,f258,f124", "3日"),
"5d": ("f164", "f12,f14,f2,f109,f164,f165,f166,f167,f168,f169,f170,f171,f172,f173,f257,f258,f124", "5日"),
"10d": ("f174", "f12,f14,f2,f160,f174,f175,f176,f177,f178,f179,f180,f181,f182,f183,f260,f261,f124", "10日"),
}.get(period)
if config is None:
raise SourceError("东方财富排行式个股资金流不支持周期 %s: %s" % (period, fallback_reason))
fid, fields, label = config
base_params = {
"fid": fid,
"po": "1",
"pz": "100",
"pn": "1",
"np": "1",
"fltt": "2",
"invt": "2",
"ut": "b2884a393a59ad64002292a3e90d46a5",
"fs": "m:0+t:6+f:!2,m:0+t:13+f:!2,m:0+t:80+f:!2,m:1+t:2+f:!2,m:1+t:23+f:!2,m:0+t:7+f:!2,m:1+t:3+f:!2",
"fields": fields,
}
total = 6000
matched: List[Dict[str, Any]] = []
for page in range(1, 61):
params = dict(base_params)
params["pn"] = str(page)
payload = self._json_get("https://push2delay.eastmoney.com/api/qt/clist/get", params, referer="https://data.eastmoney.com/")
data = payload.get("data") or {}
rows = data.get("diff") or []
total = _to_int(data.get("total")) or total
for row in rows:
if isinstance(row, dict) and str(row.get("f12")) == entity.code:
matched.append(_parse_eastmoney_stock_money_flow_rank_row(row, period, label))
if matched or page * 100 >= total or not rows:
break
if not matched:
raise SourceError("东方财富排行式个股资金流未匹配到 %s: %s" % (entity.code, fallback_reason))
return {
"scope": "stock",
"symbol": entity.symbol,
"name": matched[0].get("name") or entity.name,
"period": period,
"items": matched[:limit],
"returned_count": len(matched[:limit]),
"source_note": "东方财富资金流排行公开接口;历史明细接口失败后按排行页匹配个股",
}
def _money_flow_akshare(self, scope: str, period: str, entity: Optional[Entity], limit: int) -> Dict[str, Any]:
import akshare as ak
if scope == "stock":
if entity is None:
raise SourceError("个股资金流需要 symbol")
df = _quiet_call(ak.stock_individual_fund_flow, stock=entity.code, market=entity.market.lower())
return {"scope": scope, "symbol": entity.symbol, "period": period, "items": _df_to_records(df)[-limit:]}
indicator = {"instant": "今日", "3d": "3日", "5d": "5日", "10d": "10日", "20d": "20日"}.get(period, "今日")
sector_type = "行业资金流" if scope == "industry" else "概念资金流"
df = _quiet_call(ak.stock_sector_fund_flow_rank, indicator=indicator, sector_type=sector_type)
return {"scope": scope, "period": period, "items": _df_to_records(df)[:limit]}
def _money_flow_ths(self, scope: str, period: str, limit: int, entity: Optional[Entity] = None) -> Dict[str, Any]:
import akshare as ak
symbol = {"instant": "即时", "3d": "3日排行", "5d": "5日排行", "10d": "10日排行", "20d": "20日排行"}.get(period, "即时")
if scope == "stock":
if entity is None:
raise SourceError("个股资金流需要 symbol")
try:
matched = self._money_flow_ths_stock_by_code(period, entity, limit)
except Exception:
rows = _df_to_records(_quiet_call(ak.stock_fund_flow_individual, symbol=symbol))
matched = [row for row in rows if _row_matches_code(row, entity.code)]
if not matched:
raise SourceError("同花顺个股资金流未匹配到 %s" % entity.code)
return {
"scope": scope,
"symbol": entity.symbol,
"period": period,
"items": matched[:limit],
"returned_count": len(matched[:limit]),
"source_note": "同花顺资金流公开页",
}
if scope not in {"industry", "concept"}:
raise SourceError("同花顺资金流兜底仅支持个股/行业/概念")
func = ak.stock_fund_flow_industry if scope == "industry" else ak.stock_fund_flow_concept
rows = _df_to_records(_quiet_call(func, symbol=symbol))
return {"scope": scope, "period": period, "items": rows[:limit], "source_note": "同花顺资金流公开页"}
def _money_flow_ths_stock_by_code(self, period: str, entity: Entity, limit: int) -> List[Dict[str, Any]]:
import pandas as pd
import py_mini_racer
from akshare.stock_feature.stock_fund_flow import _get_file_content_ths
board = {"3d": "3", "5d": "5", "10d": "10", "20d": "20"}.get(period)
path = "board/%s/field/code/order/asc/page/{}/ajax/1/free/1/" % board if board else "field/code/order/asc/page/{}/ajax/1/free/1/"
url_template = "http://data.10jqka.com.cn/funds/ggzjl/%s" % path
js_code = py_mini_racer.MiniRacer()
js_code.eval(_get_file_content_ths("ths.js"))
headers = {
"Accept": "text/html, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Cache-Control": "no-cache",
"hexin-v": js_code.call("v"),
"Host": "data.10jqka.com.cn",
"Referer": "http://data.10jqka.com.cn/funds/hyzjl/",
"User-Agent": USER_AGENT,
"X-Requested-With": "XMLHttpRequest",
}
cache: Dict[int, Tuple[List[Dict[str, Any]], str]] = {}
def fetch_page(page: int) -> Tuple[List[Dict[str, Any]], str]:
if page in cache:
return cache[page]
response = self.session.get(url_template.format(page), headers=headers, timeout=self.timeout)
response.raise_for_status()
text = _decode_response(response)
tables = pd.read_html(io.StringIO(text))
rows = _df_to_records(tables[0]) if tables else []
cache[page] = (rows, text)
return cache[page]
rows, text = fetch_page(1)
total_pages = _extract_ths_total_pages(text) or 120
target = int(entity.code)
low, high = 1, total_pages
while low <= high:
page = (low + high) // 2
rows, _ = fetch_page(page)
codes = sorted(_row_code_as_int(row) for row in rows if _row_code_as_int(row) is not None)
if not codes:
break
matched = [row for row in rows if _row_matches_code(row, entity.code)]
if matched:
return matched[:limit]
if target < codes[0]:
high = page - 1
elif target > codes[-1]:
low = page + 1
else:
break
return []
def sector(self, kind: str, action: str, entity: Optional[Entity], query: str, limit: int) -> Dict[str, Any]:
chain: List[Dict[str, Any]] = []
try:
data = self._sector_akshare(kind, action, entity, query, limit)
chain.append({"source": "akshare_sector", "ok": True})
return {"data": data, "source_chain": chain, "warnings": []}
except Exception as exc:
chain.append({"source": "akshare_sector", "ok": False, "error": str(exc)})
try:
data = self._sector_ths(kind, action, entity, query, limit)
chain.append({"source": "akshare_ths_or_sina_sector", "ok": True})
return {"data": data, "source_chain": chain, "warnings": ["已从东方财富板块源回退到同花顺/新浪公开源"]}
except Exception as exc:
chain.append({"source": "akshare_ths_or_sina_sector", "ok": False, "error": str(exc)})
if action == "constituents":
return {
"data": {"kind": kind, "action": action, "board": _extract_board_name(query), "items": [], "returned_count": 0},
"source_chain": chain,
"warnings": ["板块成分股公开源暂时不可用或未匹配到板块,已返回空列表和失败详情"],
}
raise SourceError("板块公开源失败: %s" % exc)
def _sector_akshare(self, kind: str, action: str, entity: Optional[Entity], query: str, limit: int) -> Dict[str, Any]:
import akshare as ak
if action == "rank":
func = ak.stock_board_concept_name_em if kind == "concept" else ak.stock_board_industry_name_em
return {"kind": kind, "action": action, "items": _df_to_records(_quiet_call(func))[:limit]}
if action == "constituents":
name = _extract_board_name(query)
if not name:
raise SourceError("缺少板块名称")
func = ak.stock_board_concept_cons_em if kind == "concept" else ak.stock_board_industry_cons_em
return {"kind": kind, "action": action, "board": name, "items": _df_to_records(_quiet_call(func, symbol=name))[:limit]}
if action == "belong":
if entity is None:
raise SourceError("所属板块需要股票代码")
import efinance as ef
df = _quiet_call(ef.stock.get_belong_board, entity.code)
return {"kind": kind, "action": action, "symbol": entity.symbol, "items": _df_to_records(df)[:limit]}
raise SourceError("unknown sector action")
def _sector_ths(self, kind: str, action: str, entity: Optional[Entity], query: str, limit: int) -> Dict[str, Any]:
import akshare as ak
import pandas as pd
if action == "rank":
if kind == "industry":
df = _quiet_call(ak.stock_sector_spot, indicator="新浪行业")
rows = _df_to_records(df)
rows = sorted(rows, key=lambda row: _to_float(row.get("涨跌幅")) or -999999, reverse=True)
return {"kind": kind, "action": action, "items": rows[:limit]}
df = _quiet_call(ak.stock_board_concept_name_ths)
rows = _df_to_records(df)
return {"kind": kind, "action": action, "items": rows[:limit], "note": "同花顺概念板块兜底仅返回名称和代码"}
if action == "constituents":
name = _extract_board_name(query)
if not name:
raise SourceError("缺少板块名称")
listing_func = ak.stock_board_concept_name_ths if kind == "concept" else ak.stock_board_industry_name_ths
listing = _df_to_records(_quiet_call(listing_func))
matched = _match_board_row(listing, name)
if not matched:
raise SourceError("同花顺未找到板块:%s" % name)
code = str(matched.get("code") or matched.get("代码") or "").strip()
path = "gn" if kind == "concept" else "thshy"
response = self._get(
"https://q.10jqka.com.cn/%s/detail/code/%s/" % (path, code),
referer="https://q.10jqka.com.cn/",
)
tables = _quiet_call(pd.read_html, io.StringIO(_decode_response(response)))
if not tables:
raise SourceError("同花顺板块成分股表格为空")
selected = tables[0]
for table in tables:
columns = {str(column) for column in getattr(table, "columns", [])}
lowered_columns = {column.lower() for column in columns}
if {"代码", "名称"}.issubset(columns) or {"code", "name"}.issubset(lowered_columns):
selected = table
break
return {"kind": kind, "action": action, "board": matched.get("name") or matched.get("名称") or name, "items": _df_to_records(selected)[:limit]}
if action == "belong":
raise SourceError("同花顺兜底不支持个股所属板块")
raise SourceError("unknown sector action")
def fundamental(self, entity: Entity, pack: str) -> Dict[str, Any]:
chain: List[Dict[str, Any]] = []
data: Dict[str, Any] = {"symbol": entity.symbol, "pack": pack}
warnings: List[str] = []
try:
data["quote_valuation"] = self.quote_realtime(entity)["data"]
chain.append({"source": "tencent_quote_valuation", "ok": True})
except Exception as exc:
chain.append({"source": "tencent_quote_valuation", "ok": False, "error": str(exc)})
warnings.append("估值快照失败: %s" % exc)
try:
data.update(self._fundamental_akshare(entity, pack))
chain.append({"source": "akshare_fundamental", "ok": True})
except Exception as exc:
chain.append({"source": "akshare_fundamental", "ok": False, "error": str(exc)})
warnings.append("akshare 基本面 best-effort 失败: %s" % exc)
if len(data) <= 2:
raise SourceError("基本面公开源失败")
return {"data": data, "source_chain": chain, "warnings": warnings}
def _fundamental_akshare(self, entity: Entity, pack: str) -> Dict[str, Any]:
import akshare as ak
result: Dict[str, Any] = {}
if pack in {"basic", "all"}:
result["basic"] = _df_to_records(_quiet_call(ak.stock_individual_info_em, symbol=entity.code))
if pack in {"financials", "all"}:
result["financial_abstract"] = _df_to_records(_quiet_call(ak.stock_financial_abstract, symbol=entity.code))
result["financial_indicator"] = _df_to_records(_quiet_call(ak.stock_financial_analysis_indicator, symbol=entity.code))
if pack in {"holders", "all"}:
holder_count_history = _optional_ak_call(ak, "stock_zh_a_gdhs_detail_em", symbol=entity.code)
main_holders = _optional_ak_call(ak, "stock_main_stock_holder", stock=entity.code)
result["holder_count_history"] = _tail_records(holder_count_history, 40)
result["main_holders"] = _latest_records(main_holders, ["截至日期", "报告期"], 20)
result["holders_scope"] = {
"holder_count_history": "latest_40_records",
"main_holders": "latest_report_period_top_20",
}
if pack in {"dividend", "all"}:
result["dividend"] = _optional_ak_call(ak, "stock_dividend_cninfo", symbol=entity.code)
return result
def announcement(self, entity: Optional[Entity], keyword: Optional[str], limit: int) -> Dict[str, Any]:
chain: List[Dict[str, Any]] = []
try:
data = self._announcement_cninfo(entity, keyword, limit)
chain.append({"source": "cninfo_public", "ok": True})
return {"data": data, "source_chain": chain, "warnings": []}
except Exception as exc:
chain.append({"source": "cninfo_public", "ok": False, "error": str(exc)})
raise SourceError("公告公开源失败")
def _announcement_cninfo(self, entity: Optional[Entity], keyword: Optional[str], limit: int) -> Dict[str, Any]:
data = {
"pageNum": 1,
"pageSize": max(1, min(limit, 50)),
"column": "szse",
"tabName": "fulltext",
"sortName": "",
"sortType": "",
"isHLtitle": "true",
}
if entity is not None:
search_name = _canonical_entity_search_name(entity)
search_parts = [search_name or entity.code]
if keyword:
search_parts.append(_announcement_search_keyword(keyword))
data["searchkey"] = " ".join(search_parts)
elif keyword:
data["searchkey"] = _announcement_search_keyword(keyword)
payload = self._post("https://www.cninfo.com.cn/new/hisAnnouncement/query", data=data, referer="https://www.cninfo.com.cn/").json()
rows = payload.get("announcements") or []
items = []
for row in rows:
if not isinstance(row, dict):
continue
adjunct_url = row.get("adjunctUrl")
items.append(
{
"title": _strip_html(row.get("announcementTitle")),
"code": row.get("secCode"),
"name": row.get("secName"),
"announcement_time": row.get("announcementTime"),
"announcement_id": row.get("announcementId"),
"pdf_url": "https://static.cninfo.com.cn/%s" % adjunct_url if adjunct_url else None,
}
)
return {"symbol": entity.symbol if entity else None, "keyword": keyword, "items": items, "returned_count": len(items)}
def dragon_tiger(self, query_date: Optional[str], entity: Optional[Entity], limit: int) -> Dict[str, Any]:
import akshare as ak
date_text = (query_date or date.today().isoformat()).replace("-", "")
try:
df = _quiet_call(ak.stock_lhb_detail_em, start_date=date_text, end_date=date_text)
rows = _df_to_records(df)
chain = [{"source": "akshare.stock_lhb_detail_em", "ok": True}]
warnings_list = ["龙虎榜为公开源 best-effort 数据"]
except Exception as exc:
rows = []
chain = [{"source": "akshare.stock_lhb_detail_em", "ok": False, "error": str(exc)}]
warnings_list = ["龙虎榜公开源暂时不可用,已返回空列表和失败详情"]
if entity is not None:
rows = [row for row in rows if entity.code in json.dumps(row, ensure_ascii=False, default=str)]
return {"data": {"date": date_text, "symbol": entity.symbol if entity else None, "items": rows[:limit], "returned_count": min(len(rows), limit)}, "source_chain": chain, "warnings": warnings_list}
def news(self, entity: Optional[Entity], keyword: Optional[str], kind: str, limit: int) -> Dict[str, Any]:
import akshare as ak
source = "akshare.stock_research_report_em" if kind == "research" else "akshare.stock_news_em" if entity is not None else "akshare.stock_news_main_cx"
try:
if kind == "research":
if entity is None:
raise SourceError("研报/评级需要股票代码或名称")
df = _quiet_call(ak.stock_research_report_em, symbol=entity.code)
elif entity is not None:
df = _quiet_call(ak.stock_news_em, symbol=entity.code)
else:
df = _quiet_call(ak.stock_news_main_cx)
rows = _df_to_records(df)
chain = [{"source": source, "ok": True}]
warnings_list = ["新闻/研报为公开源 best-effort 数据"]
except Exception as exc:
rows = []
chain = [{"source": source, "ok": False, "error": str(exc)}]
warnings_list = ["新闻/研报公开源暂时不可用,已返回空列表和失败详情"]
non_filter_keywords = {"新闻", "快讯", "消息", "资讯", "利好", "利空", "有雷", "爆雷", "研报", "评级", "目标价", "机构评级", "机构怎么看", "机构看法"}
if keyword and keyword not in non_filter_keywords:
rows = [row for row in rows if keyword in json.dumps(row, ensure_ascii=False, default=str)]
return {
"data": {"kind": kind, "symbol": entity.symbol if entity else None, "keyword": keyword, "items": rows[:limit], "returned_count": min(len(rows), limit)},
"source_chain": chain,
"warnings": warnings_list,
}
def chip(self, entity: Entity, limit: int) -> Dict[str, Any]:
import akshare as ak
try:
df = _quiet_call(ak.stock_cyq_em, symbol=entity.code)
rows = _df_to_records(df)
chain = [{"source": "akshare.stock_cyq_em", "ok": True}]
warnings_list = ["筹码分布为公开源 best-effort 数据,字段和复权口径可能随公开源变化"]
except Exception as exc:
rows = []
chain = [{"source": "akshare.stock_cyq_em", "ok": False, "error": str(exc)}]
warnings_list = ["筹码分布公开源暂时不可用,已返回空列表和失败详情"]
return {
"data": {"symbol": entity.symbol, "items": rows[-limit:], "returned_count": min(len(rows), limit)},
"source_chain": chain,
"warnings": warnings_list,
}
def block_trade(self, query_date: Optional[str], entity: Optional[Entity], limit: int) -> Dict[str, Any]:
import akshare as ak
date_text = (query_date or date.today().isoformat()).replace("-", "")
try:
df = _quiet_call(ak.stock_dzjy_mrmx, symbol="A股", start_date=date_text, end_date=date_text)
rows = _df_to_records(df)
chain = [{"source": "akshare.stock_dzjy_mrmx", "ok": True}]
warnings_list = ["大宗交易为公开源 best-effort 数据,非交易日可能返回空列表"]
except Exception as exc:
rows = []
chain = [{"source": "akshare.stock_dzjy_mrmx", "ok": False, "error": str(exc)}]
warnings_list = ["大宗交易公开源暂时不可用,已返回空列表和失败详情"]
if entity is not None:
rows = [row for row in rows if entity.code in json.dumps(row, ensure_ascii=False, default=str)]
return {
"data": {"date": date_text, "symbol": entity.symbol if entity else None, "items": rows[:limit], "returned_count": min(len(rows), limit)},
"source_chain": chain,
"warnings": warnings_list,
}
def margin_trading(self, query_date: Optional[str], entity: Optional[Entity], limit: int) -> Dict[str, Any]:
import akshare as ak
date_text = (query_date or date.today().isoformat()).replace("-", "")
chain: List[Dict[str, Any]] = []
rows: List[Dict[str, Any]] = []
for source, func, kwargs in [
("akshare.stock_margin_detail_sse", ak.stock_margin_detail_sse, {"date": date_text}),
("akshare.stock_margin_detail_szse", ak.stock_margin_detail_szse, {"date": date_text}),
]:
try:
part = _df_to_records(_quiet_call(func, **kwargs))
rows.extend(part)
chain.append({"source": source, "ok": True})
except Exception as exc:
chain.append({"source": source, "ok": False, "error": str(exc)})
if entity is not None:
rows = [row for row in rows if entity.code in json.dumps(row, ensure_ascii=False, default=str)]
if not rows and not any(item.get("ok") for item in chain):
raise SourceError("融资融券公开源失败")
return {
"data": {"date": date_text, "symbol": entity.symbol if entity else None, "items": rows[:limit], "returned_count": min(len(rows), limit)},
"source_chain": chain,
"warnings": ["融资融券为交易所公开源 best-effort 数据,深沪字段可能不完全一致"],
}
def bond(self, action: str, entity: Optional[Entity], limit: int, days: int) -> Dict[str, Any]:
import akshare as ak
if action in {"rank", "quote"}:
df = _quiet_call(ak.bond_zh_hs_cov_spot)
rows = _df_to_records(df)
if entity is not None:
rows = [row for row in rows if entity.code in json.dumps(row, ensure_ascii=False, default=str)]
return {"data": {"action": action, "items": rows[:limit]}, "source_chain": [{"source": "akshare.bond_zh_hs_cov_spot", "ok": True}], "warnings": []}
if action == "history":
if entity is None:
raise SourceError("可转债历史需要 symbol")
chain: List[Dict[str, Any]] = []
warnings_list: List[str] = []
try:
data = self._history_tencent(entity.symbol, days=days, period="daily", adjust="qfq")
data["action"] = action
data["asset_type"] = "bond"
chain.append({"source": "tencent_fqkline", "ok": True})
return {"data": data, "source_chain": chain, "warnings": warnings_list}
except Exception as exc:
chain.append({"source": "tencent_fqkline", "ok": False, "error": str(exc)})
try:
df = _quiet_call(ak.bond_zh_hs_cov_daily, symbol=entity.code)
rows = _df_to_records(df)[-days:]
chain.append({"source": "akshare.bond_zh_hs_cov_daily", "ok": True})
except Exception as exc:
rows = []
chain.append({"source": "akshare.bond_zh_hs_cov_daily", "ok": False, "error": str(exc)})
warnings_list = ["可转债历史公开源暂时不可用,已返回空列表和失败详情"]
return {"data": {"action": action, "symbol": entity.symbol, "items": rows, "returned_count": len(rows)}, "source_chain": chain, "warnings": warnings_list}
raise SourceError("unsupported bond action")
def _akshare_call(self, name: str, *args: Any, **kwargs: Any) -> List[Dict[str, Any]]:
import akshare as ak
func = getattr(ak, name)
return _df_to_records(_quiet_call(func, *args, **kwargs))
def to_tencent_symbol(symbol: str) -> str:
normalized = normalize_symbol(symbol)
if not normalized:
return symbol.lower()
code, market = normalized.split(".", 1)
return market.lower() + code
def _decode_response(response: requests.Response) -> str:
content = response.content
for encoding in ("utf-8", "gb18030", "gbk"):
try:
return content.decode(encoding)
except UnicodeDecodeError:
continue
return response.text
def _json_unescape(text: str) -> str:
if "\\u" not in text:
return text
try:
return json.loads('"%s"' % text)
except Exception:
return text
def _to_float(value: Any) -> Optional[float]:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", "")
if text in {"", "-", "--", "None", "false"}:
return None
try:
return float(text)
except ValueError:
return None
def _to_int(value: Any) -> Optional[int]:
number = _to_float(value)
if number is None:
return None
return int(number)
def _parse_tencent_quote(provider_symbol: str, fields: List[Any]) -> Dict[str, Any]:
def field(index: int) -> str:
return str(fields[index]).strip() if index < len(fields) else ""
code = field(2)
symbol = normalize_symbol("%s.%s" % (code, provider_symbol[:2])) or provider_symbol
return {
"symbol": symbol,
"provider_symbol": provider_symbol,
"code": code,
"name": field(1),
"latest": _to_float(field(3)),
"previous_close": _to_float(field(4)),
"open": _to_float(field(5)),
"volume": _to_float(field(6)),
"market_time": field(30),
"change": _to_float(field(31)),
"change_ratio": _to_float(field(32)),
"high": _to_float(field(33)),
"low": _to_float(field(34)),
"amount": _to_float(field(37)),
"turnover_ratio": _to_float(field(38)),
"pe": _to_float(field(39)),
"amplitude": _to_float(field(43)),
"total_market_cap": _to_float(field(44)),
"float_market_cap": _to_float(field(45)),
"pb": _to_float(field(46)),
"volume_ratio": _to_float(field(49)),
}
def _parse_kline_row(row: List[Any]) -> Dict[str, Any]:
return {
"date": row[0],
"open": _to_float(row[1]),
"close": _to_float(row[2]),
"high": _to_float(row[3]),
"low": _to_float(row[4]),
"volume": _to_float(row[5]),
"amount": _to_float(row[6]) if len(row) > 6 else None,
}
def _parse_eastmoney_rank_row(row: Dict[str, Any], rank: int) -> Dict[str, Any]:
code = str(row.get("f12", "")).strip()
symbol = normalize_symbol(code) or code
return {
"rank": rank,
"symbol": symbol,
"code": code,
"name": row.get("f14"),
"latest": _to_float(row.get("f2")),
"change_ratio": _to_float(row.get("f3")),
"change": _to_float(row.get("f4")),
"volume": _to_float(row.get("f5")),
"amount": _to_float(row.get("f6")),
"amplitude": _to_float(row.get("f7")),
"turnover_ratio": _to_float(row.get("f8")),
"pe": _to_float(row.get("f9")),
"volume_ratio": _to_float(row.get("f10")),
"high": _to_float(row.get("f15")),
"low": _to_float(row.get("f16")),
"open": _to_float(row.get("f17")),
"previous_close": _to_float(row.get("f18")),
"total_market_cap": _to_float(row.get("f20")),
"float_market_cap": _to_float(row.get("f21")),
"pb": _to_float(row.get("f23")),
}
def _parse_sina_rank_row(row: Dict[str, Any], rank: int) -> Dict[str, Any]:
return {
"rank": rank,
"symbol": row.get("symbol"),
"code": row.get("code"),
"name": row.get("name"),
"latest": _to_float(row.get("trade")),
"change": _to_float(row.get("pricechange")),
"change_ratio": _to_float(row.get("changepercent")),
"volume": _to_float(row.get("volume")),
"amount": _to_float(row.get("amount")),
"turnover_ratio": _to_float(row.get("turnoverratio")),
"pe": _to_float(row.get("per")),
"pb": _to_float(row.get("pb")),
"tick_time": row.get("ticktime"),
}
def _parse_sina_money_row(row: Dict[str, Any], rank: int) -> Dict[str, Any]:
symbol = row.get("symbol")
return {
"rank": rank,
"symbol": symbol,
"code": str(symbol)[-6:] if symbol else None,
"name": row.get("name") or "",
"latest": _to_float(row.get("trade")),
"change_ratio": _to_float(row.get("changeratio")),
"amount": _to_float(row.get("amount")),
"net_amount": _to_float(row.get("netamount")),
"main_net_amount": _to_float(row.get("r0_net")),
"turnover": _to_float(row.get("turnover")),
}
def _parse_eastmoney_stock_money_flow_line(line: Any) -> Dict[str, Any]:
parts = str(line).split(",")
if len(parts) < 6:
return {}
return {
"date": parts[0],
"main_net_inflow": _to_float(parts[1]),
"small_net_inflow": _to_float(parts[2]),
"medium_net_inflow": _to_float(parts[3]),
"large_net_inflow": _to_float(parts[4]),
"super_large_net_inflow": _to_float(parts[5]),
}
def _parse_eastmoney_stock_money_flow_rank_row(row: Dict[str, Any], period: str, label: str) -> Dict[str, Any]:
if period == "instant":
return {
"code": str(row.get("f12") or ""),
"name": row.get("f14"),
"latest": _to_float(row.get("f2")),
"change_ratio": _to_float(row.get("f3")),
"period": label,
"main_net_inflow": _to_float(row.get("f62")),
"main_net_inflow_ratio": _to_float(row.get("f184")),
"super_large_net_inflow": _to_float(row.get("f66")),
"super_large_net_inflow_ratio": _to_float(row.get("f69")),
"large_net_inflow": _to_float(row.get("f72")),
"large_net_inflow_ratio": _to_float(row.get("f75")),
"medium_net_inflow": _to_float(row.get("f78")),
"medium_net_inflow_ratio": _to_float(row.get("f81")),
"small_net_inflow": _to_float(row.get("f84")),
"small_net_inflow_ratio": _to_float(row.get("f87")),
}
field_map = {
"3d": ("f127", "f267", "f268", "f269", "f270", "f271", "f272", "f273", "f274", "f275", "f276"),
"5d": ("f109", "f164", "f165", "f166", "f167", "f168", "f169", "f170", "f171", "f172", "f173"),
"10d": ("f160", "f174", "f175", "f176", "f177", "f178", "f179", "f180", "f181", "f182", "f183"),
}
change_key, main_key, main_ratio_key, super_key, super_ratio_key, large_key, large_ratio_key, medium_key, medium_ratio_key, small_key, small_ratio_key = field_map[period]
return {
"code": str(row.get("f12") or ""),
"name": row.get("f14"),
"latest": _to_float(row.get("f2")),
"change_ratio": _to_float(row.get(change_key)),
"period": label,
"main_net_inflow": _to_float(row.get(main_key)),
"main_net_inflow_ratio": _to_float(row.get(main_ratio_key)),
"super_large_net_inflow": _to_float(row.get(super_key)),
"super_large_net_inflow_ratio": _to_float(row.get(super_ratio_key)),
"large_net_inflow": _to_float(row.get(large_key)),
"large_net_inflow_ratio": _to_float(row.get(large_ratio_key)),
"medium_net_inflow": _to_float(row.get(medium_key)),
"medium_net_inflow_ratio": _to_float(row.get(medium_ratio_key)),
"small_net_inflow": _to_float(row.get(small_key)),
"small_net_inflow_ratio": _to_float(row.get(small_ratio_key)),
}
def _parse_pool_row(row: Dict[str, Any], rank: int) -> Dict[str, Any]:
code = str(row.get("c", "")).strip()
symbol = normalize_symbol(code) or code
price_raw = _to_float(row.get("p"))
stat = row.get("zttj") if isinstance(row.get("zttj"), dict) else {}
return {
"rank": rank,
"symbol": symbol,
"code": code,
"name": row.get("n"),
"latest": round(price_raw / 1000, 3) if price_raw is not None else None,
"change_ratio": _to_float(row.get("zdp")),
"amount": _to_float(row.get("amount")),
"turnover_ratio": _to_float(row.get("hs")),
"board_count": _to_int(row.get("lbc")),
"first_limit_time": _format_hhmmss(row.get("fbt")),
"last_limit_time": _format_hhmmss(row.get("lbt")),
"sealed_fund": _to_float(row.get("fund")),
"break_count": _to_int(row.get("zbc")),
"sector": row.get("hybk"),
"limit_stat_days": stat.get("days"),
"limit_stat_count": stat.get("ct"),
}
def _format_hhmmss(value: Any) -> Optional[str]:
number = _to_int(value)
if number is None:
return None
text = "%06d" % number
return "%s:%s:%s" % (text[0:2], text[2:4], text[4:6])
def _df_to_records(df: Any) -> List[Dict[str, Any]]:
if df is None:
return []
if isinstance(df, list):
return df
if hasattr(df, "to_dict"):
return df.to_dict(orient="records")
return []
def _find_row(rows: List[Dict[str, Any]], code: str) -> Optional[Dict[str, Any]]:
for row in rows:
if str(row.get("代码") or row.get("code") or row.get("股票代码") or "") == code:
return row
return None
def _row_matches_code(row: Dict[str, Any], code: str) -> bool:
for key in ["代码", "code", "股票代码", "证券代码"]:
value = row.get(key)
if value is None:
continue
text = str(value).strip()
if text.endswith(".0"):
text = text[:-2]
if text.zfill(6) == code:
return True
return False
def _row_code_as_int(row: Dict[str, Any]) -> Optional[int]:
for key in ["代码", "code", "股票代码", "证券代码"]:
value = row.get(key)
if value is None:
continue
text = str(value).strip()
if text.endswith(".0"):
text = text[:-2]
if text.isdigit():
return int(text)
return None
def _extract_ths_total_pages(text: str) -> Optional[int]:
match = re.search(r'class=["\']page_info["\'][^>]*>\s*\d+\s*/\s*(\d+)', text)
if match:
return _to_int(match.group(1))
match = re.search(r">\s*\d+\s*/\s*(\d+)\s*<", text)
if match:
return _to_int(match.group(1))
return None
def _normalize_akshare_quote(row: Dict[str, Any], entity: Entity) -> Dict[str, Any]:
return {
"symbol": entity.symbol,
"code": entity.code,
"name": _pick(row, ["名称", "股票名称", "name"]),
"latest": _to_float(_pick(row, ["最新价", "最新", "price"])),
"change_ratio": _to_float(_pick(row, ["涨跌幅", "change_ratio"])),
"change": _to_float(_pick(row, ["涨跌额", "change"])),
"volume": _to_float(_pick(row, ["成交量", "volume"])),
"amount": _to_float(_pick(row, ["成交额", "amount"])),
"turnover_ratio": _to_float(_pick(row, ["换手率", "turnover"])),
"volume_ratio": _to_float(_pick(row, ["量比", "volume_ratio"])),
"pe": _to_float(_pick(row, ["市盈率-动态", "市盈率", "pe"])),
"pb": _to_float(_pick(row, ["市净率", "pb"])),
}
def _pick(row: Dict[str, Any], keys: Iterable[str]) -> Any:
for key in keys:
if key in row:
return row[key]
return None
def _optional_ak_call(module: Any, name: str, **kwargs: Any) -> List[Dict[str, Any]]:
func = getattr(module, name, None)
if func is None:
return []
try:
return _df_to_records(_quiet_call(func, **kwargs))
except TypeError:
return _df_to_records(_quiet_call(func))
def _tail_records(records: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]:
if limit <= 0:
return []
return records[-limit:]
def _latest_records(records: List[Dict[str, Any]], date_keys: Iterable[str], limit: int) -> List[Dict[str, Any]]:
if not records or limit <= 0:
return []
latest_value: Optional[str] = None
for row in records:
for key in date_keys:
value = row.get(key)
if value is None:
continue
text = str(value)
if latest_value is None or text > latest_value:
latest_value = text
break
if latest_value is None:
return records[:limit]
latest_rows = [
row
for row in records
if any(row.get(key) is not None and str(row.get(key)) == latest_value for key in date_keys)
]
return latest_rows[:limit]
def _strip_html(value: Any) -> str:
text = str(value or "")
return re.sub(r"<[^>]+>", "", text).strip()
def _canonical_entity_search_name(entity: Entity) -> Optional[str]:
canonical_by_code = {
"600519": "贵州茅台",
"300750": "宁德时代",
"300059": "东方财富",
"600036": "招商银行",
"002594": "比亚迪",
"688981": "中芯国际",
"603288": "海天味业",
"600031": "三一重工",
"002714": "牧原股份",
"603501": "韦尔股份",
"600150": "中国船舶",
"601127": "赛力斯",
"601138": "工业富联",
"300274": "阳光电源",
"300308": "中际旭创",
"601088": "中国神华",
"002371": "北方华创",
"688012": "中微公司",
}
return canonical_by_code.get(entity.code) or entity.name
def _announcement_search_keyword(keyword: str) -> str:
mapping = {
"年报": "年度报告",
"半年报": "半年度报告",
"一季报": "第一季度报告",
"三季报": "第三季度报告",
}
return mapping.get(keyword, keyword)
def _extract_board_name(query: str) -> Optional[str]:
text = re.sub(r"\s+", "", query or "")
aliases = [
"低空经济",
"人形机器人",
"机器人",
"CPO",
"算力",
"AI",
"光伏",
"猪肉",
"券商",
"证券",
"银行",
"白酒",
"半导体",
"新能源车",
]
for alias in aliases:
if alias.lower() in text.lower():
return alias
for noise in [
"有哪些股票",
"哪些股票",
"有什么股票",
"成分股",
"板块",
"行业",
"概念",
"包含",
"排行",
"排名",
"有哪些",
"哪些",
"有啥票",
"有啥股票",
"都有谁",
"都有啥",
"方向",
"这块",
"这条线",
"那条线",
"谁在涨",
"今天",
"强不强",
"咋样",
"怎么样",
"股票",
]:
text = text.replace(noise, " ")
text = re.sub(r"\s+", "", text)
return text or None
def _match_board_row(rows: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]:
for row in rows:
row_name = str(row.get("name") or row.get("名称") or row.get("板块名称") or "")
if row_name == name:
return row
for row in rows:
row_name = str(row.get("name") or row.get("名称") or row.get("板块名称") or "")
if name in row_name or row_name in name:
return row
return None
def _quiet_call(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
sink = io.StringIO()
proxy_names = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "NO_PROXY", "no_proxy"]
env = os.environ
saved = {name: env.get(name) for name in proxy_names}
for name in proxy_names:
if name.lower() == "no_proxy":
env[name] = "*"
else:
env.pop(name, None)
try:
with redirect_stdout(sink), redirect_stderr(sink):
return func(*args, **kwargs)
finally:
for name, value in saved.items():
if value is None:
env.pop(name, None)
else:
env[name] = value
FILE:scripts/stockline_cli.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from pathlib import Path
import sys
import warnings
warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL.*")
RUNTIME_DIR = Path(__file__).resolve().parent / "runtime"
if RUNTIME_DIR.is_dir():
runtime_path = str(RUNTIME_DIR)
if runtime_path not in sys.path:
sys.path.insert(0, runtime_path)
from freestocklineskill_runtime.cli import main
if __name__ == "__main__":
raise SystemExit(main())
Use when the user needs Tonghuashun iFinD market, report, factor, screening, calendar, or other financial data, and all data must come from iFinD after succe...
---
name: tonghuashun-ifind-skill
description: Use when the user needs Tonghuashun iFinD market, report, factor, screening, calendar, or other financial data, and all data must come from iFinD after successful authentication.
metadata:
openclaw:
requires:
bins: ["python3"]
---
# tonghuashun-ifind-skill
当 OpenClaw、Hermes、Codex 或其他 Agent 需要给同花顺 iFinD 用户做金融数据查询时,优先使用这个 skill。
这个 skill 的产品定位只有一句话:给已经有同花顺 iFinD 账号的用户,一个能听懂自然语言的查询入口。
## 给安装 Agent 的最短指令
如果你是负责安装这个 skill 的 Agent,照下面做,不要发明其它路径:
```bash
bash scripts/install_skill.sh
```
安装结果必须是:
```text
~/.openclaw/workspace/skills/tonghuashun-ifind-skill
```
安装后先按同花顺 iFinD 官方方式完成鉴权:用户登录 iFinD 超级命令客户端或网页版账号详情,复制 `refresh_token`,再让 skill 换取并缓存 `access_token`。
Agent 必须这样引导用户取 token:
```text
请先打开同花顺 iFinD 超级命令客户端,进入账号详情,复制 refresh_token。
如果你用网页端,可以打开:
https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails
登录后在账号详情里复制 refresh_token。复制后发给我,我会只用它换取 access_token 并缓存,不会要求你的同花顺用户名或密码。
```
```bash
python3 tonghuashun-ifind-skill/scripts/ifind_cli.py auth-set-refresh-token \
--refresh-token "$IFIND_REFRESH_TOKEN"
```
如果用户已经给了双 token,才直接注入:
```bash
python3 tonghuashun-ifind-skill/scripts/ifind_cli.py auth-set-tokens \
--access-token "$IFIND_ACCESS_TOKEN" \
--refresh-token "$IFIND_REFRESH_TOKEN"
```
查询时永远先用自然语言入口:
```bash
python3 tonghuashun-ifind-skill/scripts/ifind_cli.py smart-query \
--query "查一下贵州茅台近三年营收和毛利率"
```
## 核心规则
1. 强制 iFinD 鉴权:查询前必须有可用 `access_token`,或可用 `refresh_token` 能续期。
2. 所有数据只来自同花顺 iFinD API;不要使用腾讯财经、东方财富或其它公开源补数据。
3. 自然语言查询是第一入口:常见问题首选 `smart-query`,由 skill 负责把用户原话路由到 iFinD。
4. `quote-realtime`、`quote-history`、`market-snapshot`、`fundamental-basic` 是明确场景下的稳定命令。
5. 用户给正式中文股票名时,先用 iFinD `/smart_stock_picking` 查询股票代码和简称,再用解析出的代码调用行情/历史等稳定接口;不要维护全量本地股票表。
6. 用户给高频口语简称或昵称时,例如“茅台、宁王、招行、东财、工行、中芯、迈瑞、药明、平安”,允许用内置小型别名纠偏,避免 iFinD 把口语词误识别成无关股票;行情、财务、研报等实际数据仍必须来自 iFinD。
7. 涨停、A 股榜单、个股画像、资金流、公告、研报、龙虎榜、两融、北向、股东、持仓、分红、解禁、停复牌、概念板块和新股等 A 股常见查询,主要通过 `/smart_stock_picking` 走 iFinD;交易日 / 休市日走 `/date_sequence`。
8. 本地规则没看懂的自然语言问题,会默认交给 iFinD `/smart_stock_picking`,不要让 Agent 先手写 endpoint。
9. `api-call` 只用于高级兜底:`smart-query`、iFinD 自然语言透传和 `endpoint-list` / `endpoint-call` 都不够时再用。
10. 官方鉴权主路径是 `refresh_token -> /get_access_token -> access_token`;不要替用户完成浏览器登录。
11. 不要向用户回显 `access_token` 或 `refresh_token`。
12. 没有可用 iFinD token 时不要继续查询;先用上面的固定话术引导用户去 iFinD 超级命令账号详情复制 `refresh_token`,再执行 `auth-set-refresh-token`。
13. iFinD 返回错误、权限不足、账号无对应接口权限、名称歧义或无法识别证券名称时,直接说明 iFinD 调用失败或需要用户补充更精确名称/代码,不要切换到非同花顺数据源。
## 鉴权顺序
1. 先复用 `~/.openclaw/tonghuashun-ifind-skill/token_state.json` 里的缓存 token。
2. 如果 `access_token` 过期,自动使用 `refresh_token` 调用 `/get_access_token` 续期。
3. 如果没有可用 token,要求用户登录 iFinD 超级命令客户端或网页版账号详情,复制官方 `refresh_token`。
4. 拿到 `refresh_token` 后执行 `auth-set-refresh-token`,skill 会调用 `/get_access_token` 并保存双 token。
5. 只有用户已经明确提供 `access_token` 和 `refresh_token` 时,才执行 `auth-set-tokens`。
6. 只接收用户提供的 token,不替用户完成浏览器登录,不接收 iFinD 用户名密码。
用户问“token 在哪拿”时,直接回答:
1. 打开同花顺 iFinD 超级命令客户端,进入账号详情,复制 `refresh_token`。
2. 或打开网页版账号详情:`https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails`。
3. 登录后找到并复制 `refresh_token`,只把这个 token 提供给 Agent。
4. Agent 执行 `auth-set-refresh-token --refresh-token "$IFIND_REFRESH_TOKEN"`。
5. 不要让用户提供同花顺用户名或密码,不要回显 token。
官方文档入口:
- iFinD HTTP 接口使用说明 / 鉴权说明:`https://quantapi.51ifind.com/gwstatic/static/ds_web/quantapi-web/help-center/deploy.html`
- iFinD Python HTTP 示例:`https://quantapi.51ifind.com/gwstatic/static/ds_web/quantapi-web/example.html`
- iFinD 网页版超级命令账号详情:`https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails`
## 命令面
- `auth-set-refresh-token`
- `auth-set-tokens`
- `smart-query`
- `quote-realtime`
- `quote-history`
- `market-snapshot`
- `fundamental-basic`
- `endpoint-list`
- `endpoint-call`
- `api-call`
- `basic-data`
- `smart-pick`
- `report-query`
- `date-sequence`
## 调用建议
- 个股最新价、历史走势、大盘快照、基础财务指标、涨停数据、A 股榜单、个股画像、资金流、公告、研报、龙虎榜、两融、北向、股东、持仓、分红、解禁、停复牌、概念板块、新股、交易日、复杂筛选和行业/主题查询,优先用 `smart-query`。
- 用户输入正式中文股票名时,`smart-query` 会用 iFinD 自身能力解析证券代码;用户输入常见口语简称或昵称时,`smart-query` 会先做小型别名纠偏,再把请求发到 iFinD;如果名称仍有歧义,就让用户补充完整简称或 6 位代码。
- A 股常见问法只要不是明确的行情/历史行情/交易日稳定命令,就交给 iFinD;多数问题保留用户原话,少数“有啥/怎么样/啥消息”等口语会改写成 iFinD 更稳定的“最近公告/分红记录/研报”等正式词,不要把“分红/龙虎榜/公告/北向持股”等误路由成实时股价。
- 如果用户请求已经非常明确,也可以直接用稳定命令:`quote-realtime`、`quote-history`、`market-snapshot`、`fundamental-basic`。
- 如果没有可用 iFinD token,先用官方 `refresh_token` 鉴权,不要尝试其它数据源。
- 常见路由没命中时,`smart-query` 会把用户原话交给 iFinD `/smart_stock_picking`;只有 iFinD 也无法处理时,才看 `endpoint-list`。
- 只有在自然语言入口和命名接口目录都不够时,才去读 [references/routing.md](references/routing.md) 和 [references/use-cases.md](references/use-cases.md),然后决定是否使用 `api-call`。
- payload 保持 iFinD 原始 JSON 对象,不要把 iFinD 查询语义二次改写成别的结构。
- 如果 `smart-query` 返回需要手动查接口,就先读本地路由文档和 use cases;如果文档里仍找不到合适接口,就明确告诉用户当前 skill 未覆盖该 iFinD 能力,不要乱猜 endpoint。
## 可选大模型路由
默认使用本地确定性路由。需要强化复杂自然语言解析时,可以启用 OpenAI-compatible Chat Completions 路由器:
```bash
export IFIND_ROUTE_LLM_ENABLED=1
export IFIND_ROUTE_LLM_API_KEY="$OPENAI_API_KEY"
export IFIND_ROUTE_LLM_MODEL="gpt-4o-mini"
```
可选变量:
- `IFIND_ROUTE_LLM_BASE_URL`
- `IFIND_ROUTE_LLM_TIMEOUT`
- `IFIND_ROUTE_LLM_MIN_CONFIDENCE`
大模型只允许输出 iFinD 路由计划。低置信度、无效返回或模型调用失败时,skill 会回到本地确定性路由。
## 外部页面
- ClawHub / OpenClaw: `https://clawhub.ai/etherstrings/tonghuashun-ifind`
- Hermes Agent GitHub skill 源: `https://github.com/Etherstrings/tonghuashun-ifind-skill/tree/main/tonghuashun-ifind-skill`
详细示例见 [references/usage.md](references/usage.md),能力边界先看 [references/capability-matrix.md](references/capability-matrix.md),路由规则见 [references/routing.md](references/routing.md),常见用户问法示例见 [references/use-cases.md](references/use-cases.md)。如果要查看当前已封装的命名接口,直接运行 `endpoint-list`。
FILE:agents/openai.yaml
interface:
display_name: "tonghuashun-ifind-skill"
short_description: "给同花顺 iFinD 用户使用的自然语言查询 skill;清晰引导用户去 iFinD 超级命令账号详情复制 refresh_token,再优先用 smart-query 查询。"
default_prompt: "当用户需要同花顺 iFinD 可提供的市场、财务、研报、选股或日期数据时,优先使用 $tonghuashun-ifind-skill。安装路径必须是 ~/.openclaw/workspace/skills/tonghuashun-ifind-skill。安装后先引导用户打开 iFinD 超级命令客户端的账号详情,或打开网页版账号详情 https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails,登录后复制 refresh_token;拿到 refresh_token 后执行 auth-set-refresh-token。不要要求同花顺用户名或密码,不要回显 token。查询永远先走 smart-query,让用户用自然语言表达需求;茅台、宁王、招行、东财、工行、中芯、迈瑞、药明、平安等口语简称可以被纠偏后再查 iFinD。本地规则没命中时,smart-query 会把原问题交给 iFinD smart_stock_picking。没有可用 iFinD token 时不要继续查询,也不要切换到腾讯财经、东方财富或其它公开源。只有自然语言入口和 endpoint-list 都不够时才考虑 api-call。"
policy:
allow_implicit_invocation: true
FILE:references/capability-matrix.md
# 能力矩阵
这份文档专门给 Agent 看。
目的不是讲实现细节,而是明确回答三件事:
1. 这个 skill 当前到底能处理什么问题
2. 哪些能力需要 iFinD 鉴权
3. 哪些能力当前不要假装能做
## 使用规则
- 所有已支持能力都要求 iFinD 鉴权。
- 不接入公开免费源;没有可用 token、refresh 失败、账号无权限或 iFinD API 返回错误时,直接把 iFinD 失败状态交还给用户。
- 自然语言问题先走 `smart-query`。
- 本地规则没命中特定能力时,`smart-query` 会把用户原话交给 iFinD `/smart_stock_picking`;少数“有啥/怎么样/昵称”口语会先改写成 iFinD 更稳定的正式查询词。
- 只有自然语言入口和命名接口都不够时,才用 `api-call`。
## 总表
| 能力 | 当前状态 | 数据来源 | 主入口 | 备注 |
|------|----------|----------|--------|------|
| 个股实时行情 | 已支持 | iFinD | `smart-query` / `quote-realtime` | 必须鉴权 |
| 个股历史走势 | 已支持 | iFinD | `smart-query` / `quote-history` | 必须鉴权 |
| 单日开盘/收盘/最高/最低 | 已支持 | iFinD | `smart-query` / `quote-history` | 必须鉴权 |
| 大盘 / 指数快照 | 已支持 | iFinD | `smart-query` / `market-snapshot` | 必须鉴权 |
| 涨停数据 | 已支持 | iFinD | `smart-query` | 走 `/smart_stock_picking` |
| A 股榜单 | 已支持 | iFinD | `smart-query` | 走 `/smart_stock_picking` |
| 成交额榜 | 已支持 | iFinD | `smart-query` | 限制条件保留在自然语言 `searchstring` |
| 涨幅榜 / 跌幅榜 | 已支持 | iFinD | `smart-query` | 限制条件保留在自然语言 `searchstring` |
| 换手率榜 | 已支持 | iFinD | `smart-query` | 限制条件保留在自然语言 `searchstring` |
| 振幅榜 | 已支持 | iFinD | `smart-query` | 限制条件保留在自然语言 `searchstring` |
| 量比榜 | 已支持 | iFinD | `smart-query` | 限制条件保留在自然语言 `searchstring` |
| 基础财务指标 | 已支持 | iFinD | `smart-query` / `fundamental-basic` | 固定查询财务、估值、预测三组模板 |
| 精确财务指标 | 已支持 | iFinD | `smart-query` | 例如营收、毛利率、市盈率、总市值;保留原问题交给 iFinD |
| 个股画像 / 主营业务 | 已支持 | iFinD | `smart-query` | 走 `/smart_stock_picking` |
| 资金流相关问法 | 已支持 | iFinD | `smart-query` | 走 `/smart_stock_picking` |
| 公告摘要 / 公告检索 | 已支持 | iFinD | `smart-query` | 走 `/smart_stock_picking`;PDF 原文下载仍需手动接口确认 |
| 研报 / 评级 / 目标价 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 龙虎榜 / 大宗交易 / 异动 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 融资融券 / 北向 / 沪深股通 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 股东 / 持仓 / 机构持仓 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 分红派息 / 送转 / 解禁 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 停牌复牌 / 风险警示 / 退市 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 概念板块 / 题材 / 产业链 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 新股申购 / 中签 / 发行上市 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 交易日 / 休市日 | 已支持 | iFinD | `smart-query` | 走 `/date_sequence`,返回的 `time` 字段为交易日序列 |
| 复杂筛选 / 选股条件组合 | 可自然语言透传 | iFinD | `smart-query` | 透传到 `/smart_stock_picking` |
| 专业接口 | 透传能力 | iFinD | `report-query` / `date-sequence` / `api-call` | 自然语言入口不够时再用 |
| 全市场分位筛选 | 当前无稳定能力 | iFinD 理论可做 | 无 | 例如“前 15% 市值的沪深股票” |
## A. 必须先鉴权
以下能力即使看起来可以从公开网页拿到,也必须通过 iFinD:
- 个股实时行情
- 个股历史走势
- 单日开盘价 / 收盘价 / 高低点
- 大盘 / 指数快照
- 涨停数据
- A 股榜单
- 成交额榜、涨跌幅榜、换手率榜、振幅榜、量比榜
- 基本面、画像、资金流
- 公告、研报、龙虎榜、两融、北向、股东、持仓、分红、解禁、停复牌、概念板块、新股、交易日
如果没有可用 iFinD token,返回 `auth_required`,不要继续查其它来源。
## B. 可选大模型路由
如果配置了 `IFIND_ROUTE_LLM_ENABLED=1` 和 API key,`smart-query` 会先尝试用大模型生成 iFinD 路由计划。
大模型路由的边界:
- 只能输出已支持 intent 和 iFinD payload
- 不能输出公开源 provider
- 不能输出 `fallback_type`
- 低置信度或解析失败时回到本地确定性路由
## C. 复杂自然语言怎么处理
以下问题不要求 Agent 自己拆 payload,先交给 `smart-query`:
- `筛一下新能源车产业链里市盈率低于30且近一个月放量的股票`
- `查一下贵州茅台近三年营收和毛利率`
- `找一下今天主力资金流入靠前的半导体股票`
- `按成交额看一下最近市场最活跃的行业`
处理结果:
- 本地规则能识别的,走稳定路由
- 本地规则识别不了的,走 `generic_smart_query`
- `generic_smart_query` 会调用 `/smart_stock_picking`
- 数据仍然只来自 iFinD
## D. 当前不要假装能做
以下问题当前不要告诉用户“已经支持”:
- `帮我找前15%市值的沪深股票`
- `筛出市值前 20% 且换手率大于 3% 的股票`
- `按多个条件做全市场选股并返回结果`
原因:
- 这类问题超出当前已验证的稳定路由
- 可以先让 `smart-query` 透传给 iFinD
- 如果 iFinD 返回失败,再告诉用户当前没有稳定覆盖,不要自己乱拼 payload
对外回复应使用类似口径:
`当前 tonghuashun-ifind-skill skill 还没有稳定覆盖这类全市场筛选能力。`
## E. Agent 决策口诀
- 所有查询都先确认 iFinD 鉴权
- 用户只要是自然语言查询,先用 `smart-query`
- 不要为了“看起来更专业”先写 `api-call`
- `smart-query` 失败后再看 `endpoint-list`
- 找不到稳定接口就明确说当前未覆盖,不乱猜 payload
FILE:references/full-examples.md
# 全面例子
这份文档专门给 Agent 和开发者看,目标是把当前命令面用一组真实 A 股例子串起来。
说明:
- 所有查询都必须先完成 iFinD 鉴权
- `smart-query` 和稳定路由是当前最推荐的入口
- 常见路由不够时,先看 `endpoint-list` / `endpoint-call`
- `basic-data`、`smart-pick`、`report-query`、`date-sequence` 属于透传 wrapper
- 如果你的 iFinD 账号对某个 endpoint 有更严格的字段要求,以你账号对应文档为准
运行前请先把 `{baseDir}` 替换成 skill 根目录。
## 1. 一套完整流程
### 1.1 官方 refresh_token 鉴权
```bash
python3 {baseDir}/scripts/ifind_cli.py auth-set-refresh-token \
--refresh-token "$IFIND_REFRESH_TOKEN"
```
适用场景:
- 用户已经从 iFinD 超级命令客户端或网页版账号详情复制了 `refresh_token`
- 需要按官方 HTTP 接口方式换取并缓存 `access_token`
### 1.2 手动注入双 token
```bash
python3 {baseDir}/scripts/ifind_cli.py auth-set-tokens \
--access-token "$IFIND_ACCESS_TOKEN" \
--refresh-token "$IFIND_REFRESH_TOKEN"
```
适用场景:
- 你已经有可用的 `access_token` 和 `refresh_token`
### 1.3 先用自然语言问一个最常见问题
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "看看贵州茅台现在股价"
```
这条命令会命中:
- intent: `quote_realtime`
- endpoint: `/real_time_quotation`
### 1.4 再问一个历史走势问题
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "看下宁德时代近一个月走势"
```
这条命令会命中:
- intent: `quote_history`
- endpoint: `/cmd_history_quotation`
### 1.5 再问一个榜单问题
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "A股成交额榜前十"
```
这条命令会命中:
- intent: `leaderboard_screen`
- endpoint: `/smart_stock_picking`
- payload: `{"searchstring":"A股成交额榜前十","searchtype":"stock"}`
## 2. `smart-query` 全路由真实例子
### 2.1 个股实时行情
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "看看贵州茅台现在股价"
```
关注返回字段:
- `data.intent`
- `data.entity.symbol`
- `data.response`
### 2.2 个股历史走势
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "贵州茅台最近一周表现"
```
关注返回字段:
- `data.intent`
- `data.request.payload.startdate`
- `data.request.payload.enddate`
- `data.response`
### 2.3 大盘 / 指数快照
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "沪深300现在怎么样"
```
关注返回字段:
- `data.intent`
- `data.request.payload.codes`
- `data.response`
### 2.4 基础财务指标
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "看看宁德时代基本面"
```
关注返回字段:
- `data.intent`
- `data.request.payload.searchstrings`
- `data.results.financials`
- `data.results.valuation`
- `data.results.forecast`
### 2.5 涨停数据
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "今天的A股涨停数据"
```
关注返回字段:
- `data.intent`
- `data.request.payload.searchstring`
- `data.response`
### 2.6 A 股榜单
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "今日涨幅榜前二十"
```
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "量比榜前十"
```
关注返回字段:
- `data.intent`
- `data.request.payload.searchstring`
- `data.response`
### 2.7 个股画像 / 主营业务
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "贵州茅台主营业务是什么"
```
关注返回字段:
- `data.intent`
- `data.entity.symbol`
- `data.response`
### 2.8 资金流
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "今天主力资金流入前十"
```
关注返回字段:
- `data.intent`
- `data.request.payload.searchstring`
- `data.response`
### 2.9 A 股常见数据查询
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "宁德时代融资余额和北向持股情况"
```
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "贵州茅台最近公告、分红记录和龙虎榜"
```
这类问题不要因为出现股票名就改查实时行情。关注返回字段:
- `data.intent`,通常是 `generic_smart_query`
- `data.request.payload.searchstring`
- `data.response`
### 2.10 复杂自然语言筛选
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "筛一下新能源车产业链里市盈率低于30且近一个月放量的股票"
```
这类问题不要让 Agent 先拆 endpoint 和 payload。关注返回字段:
- `data.intent`,通常是 `generic_smart_query`
- `data.request.payload.searchstring`
- `data.response`
## 3. 显式稳定命令真实例子
### 3.1 `quote-realtime`
```bash
python3 {baseDir}/scripts/ifind_cli.py quote-realtime --symbol 600519
```
适合:
- 已经知道证券代码
- 只需要实时行情
### 3.2 `quote-history`
```bash
python3 {baseDir}/scripts/ifind_cli.py quote-history \
--symbol 300750 \
--days 30
```
```bash
python3 {baseDir}/scripts/ifind_cli.py quote-history \
--symbol 600004.SH \
--start-date 2026-04-21 \
--end-date 2026-04-21
```
适合:
- 已经知道证券代码
- 需要明确日期窗口
### 3.3 `market-snapshot`
```bash
python3 {baseDir}/scripts/ifind_cli.py market-snapshot
python3 {baseDir}/scripts/ifind_cli.py market-snapshot --symbol 沪深300
```
### 3.4 `fundamental-basic`
```bash
python3 {baseDir}/scripts/ifind_cli.py fundamental-basic --symbol 300750
```
## 4. 命名接口目录
查看目录:
```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-list
```
按名字调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-call \
--name real_time_quote \
--payload '{"codes":"600519.SH","indicators":"open,high,low,latest,changeRatio,change,preClose,volume,amount"}'
```
```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-call \
--name history_quote \
--payload '{"codes":"600004.SH","indicators":"open,close,high,low,volume","startdate":"2026-04-21","enddate":"2026-04-21"}'
```
## 5. 原始薄封装
```bash
python3 {baseDir}/scripts/ifind_cli.py basic-data \
--payload '{"codes":"300750.SZ","indicators":"ths_close_price_stock"}'
python3 {baseDir}/scripts/ifind_cli.py smart-pick \
--payload '{"searchstring":"今天的A股涨停数据","searchtype":"stock"}'
python3 {baseDir}/scripts/ifind_cli.py report-query \
--payload '{"codes":"300750.SZ"}'
python3 {baseDir}/scripts/ifind_cli.py date-sequence \
--payload '{"startdate":"2026-04-01","enddate":"2026-04-30"}'
```
## 6. 可选 LLM 路由
```bash
export IFIND_ROUTE_LLM_ENABLED=1
export IFIND_ROUTE_LLM_API_KEY="$OPENAI_API_KEY"
export IFIND_ROUTE_LLM_MODEL="gpt-4o-mini"
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "帮我看一下茅台四月以来的日线表现"
```
如果大模型返回低置信度或不可解析结果,skill 会自动回到本地确定性路由。
## 7. 失败处理示例
没有可用 token 时:
```json
{
"ok": false,
"error": {
"type": "auth_required"
}
}
```
处理方式:
1. 先运行 `auth-set-refresh-token`
2. 鉴权成功后重试原查询
3. 不要使用其它数据源替代
FILE:references/routing.md
# 路由规则
这个 skill 的目标不是把所有 iFinD API 都自动猜出来,而是先把高频问题做成稳定路由。
如果你要先判断“这个能力有没有、该走哪个入口”,先看:
- [能力矩阵](capability-matrix.md)
## 优先顺序
1. 对于常见问题,优先使用 `smart-query`
2. 如果请求已经很明确,也可以直接使用显式稳定命令
3. 如果需要更多已封装接口,先用 `endpoint-list` 查看目录,再用 `endpoint-call`
4. 本地规则没有稳定命中时,`smart-query` 会透传到 iFinD `/smart_stock_picking`
5. 只有在自然语言入口和命名接口目录都未覆盖时,才考虑 `api-call`
## 强制 iFinD 数据源
所有查询都必须先完成 iFinD 鉴权。
- 没有 token 或 refresh 失败:返回 `auth_required`
- iFinD API 返回错误:保留 iFinD 错误并返回
- 账号无接口权限:直接告诉用户 iFinD 权限不足
- 不切换到腾讯财经、东方财富或其它公开源
## 可选 LLM 路由
默认路由是本地确定性规则。配置 `IFIND_ROUTE_LLM_ENABLED=1` 后,`smart-query` 会先调用大模型生成路由计划。
LLM 路由只能输出:
- 已支持 intent
- iFinD endpoint
- iFinD payload
- 股票或指数标的
- 日期窗口
LLM 路由不能输出:
- 非 iFinD provider
- `fallback_type`
- 公开源 URL
- 与 iFinD payload 无关的字段
低置信度或模型失败时,自动回到本地规则。
## 当前内置支持
### 0. 口语简称 / 昵称纠偏
适用说法:
- 茅台咋样
- 宁王今天咋样
- 招行现在多少
- 东财最近走势
- 中芯、迈瑞、药明、工行、平安等高频简称
默认规则:
- 正式中文名优先用 iFinD `/smart_stock_picking` 解析代码
- 高频口语简称允许用内置小型别名纠偏,避免 iFinD 把昵称误识别成无关股票
- 纠偏只用于确定 iFinD 证券代码;实际数据仍调用 iFinD 行情、历史或 `/smart_stock_picking`
### 1. 个股最新价
适用说法:
- 某股票现在股价
- 最新价
- 现价
- 行情
实际接口:
- `/real_time_quotation`
### 2. 个股历史走势
适用说法:
- 近一个月走势
- 最近一周
- 历史行情
- K线
- 指定日期开盘价、收盘价、最高价、最低价
实际接口:
- `/cmd_history_quotation`
默认规则:
- 没给时间时,默认最近 30 天
### 3. 大盘或指数快照
适用说法:
- 看一下大盘
- 看指数
- 看盘面
默认指数包:
- 上证指数 `000001.SH`
- 深证成指 `399001.SZ`
- 创业板指 `399006.SZ`
- 沪深300 `000300.SH`
实际接口:
- `/real_time_quotation`
### 4. 基础财务指标
适用说法:
- 基本面
- 财务
- 估值
- 市盈率 / 市净率 / 市值
实际接口:
- `/smart_stock_picking`
当前会固定查询三组模板:
- 财务指标
- 估值指标
- 预测指标
### 5. 涨停数据
适用说法:
- 今天的A股涨停数据
- 今日涨停
- 涨停板
- 封板数据
实际接口:
- `/smart_stock_picking`
默认规则:
- 直接把用户原始问题作为 `searchstring`
- `searchtype` 固定为 `stock`
### 6. A 股榜单查询
适用说法:
- A股成交额榜前十
- 今日涨幅榜
- 跌幅榜前二十
- 换手率排行
- 振幅榜
- 量比榜
实际接口:
- `/smart_stock_picking`
默认规则:
- 直接把用户原始问题作为 `searchstring`
- `searchtype` 固定为 `stock`
- 排名方向和数量保留在自然语言里交给 iFinD 解析
### 7. 个股画像 / 主营业务
适用说法:
- 贵州茅台主营业务是什么
- 宁德时代公司简介
- 这家公司是做什么的
实际接口:
- `/smart_stock_picking`
默认规则:
- 先解析股票标的
- 再把用户原始问题作为 `searchstring`
### 8. 资金流问题
适用说法:
- 今天主力资金流入前十
- 某股票资金流向
- 资金净流入排行
实际接口:
- `/smart_stock_picking`
默认规则:
- 直接把用户原始问题作为 `searchstring`
- `searchtype` 固定为 `stock`
### 9. A 股常见自然语言查询
适用说法:
- 贵州茅台最近公告
- 贵州茅台分红记录
- 贵州茅台龙虎榜
- 宁德时代融资余额和北向持股情况
- 宁德时代限售解禁安排
- 宁德时代所属概念和产业链
- 明天A股有哪些新股申购
实际接口:
- `/smart_stock_picking`
默认规则:
- 默认把用户原始问题作为 `searchstring`
- 如果用户说“公告有啥、分红怎么样、有啥研报、啥消息”这类口语,先改写成 iFinD 更稳定的正式词,例如“最近公告、分红记录、研报”
- `searchtype` 固定为 `stock`
- intent: `generic_smart_query`
- 不要因为问题里出现了股票名,就把公告、分红、龙虎榜、两融、北向持股等问法误路由成实时行情
### 10. 交易日 / 休市日
适用说法:
- 下一个交易日是什么时候
- 下个交易日是哪天
- 明天开不开盘
- 今天A股休市吗
实际接口:
- `/date_sequence`
默认规则:
- 使用上证指数 `000001.SH` 的 iFinD 日期序列能力
- `functionpara` 固定为 `{"Days": "Tradedays", "Fill": "Omit"}`
- 返回里的 `time` 字段就是 iFinD 给出的交易日序列
### 11. 复杂自然语言泛化查询
适用说法:
- 筛一下新能源车产业链里市盈率低于30且近一个月放量的股票
- 查一下贵州茅台近三年营收和毛利率
- 找一下半导体行业今天主力资金流入靠前的股票
- 看一下最近成交额活跃的行业
实际接口:
- `/smart_stock_picking`
默认规则:
- 本地规则没命中稳定路由时,直接把用户原始问题作为 `searchstring`
- `searchtype` 固定为 `stock`
- intent: `generic_smart_query`
- 不要求 Agent 先手写 endpoint 或 payload
## 什么时候不要猜
以下情况不要直接乱拼 `api-call`:
- 公告 PDF 下载
- 原文下载链接
- skill 里没写过的细分接口
- 你不确定 payload 结构
这时应该:
1. 回到本文件和 `usage.md`
2. 如果仍然没有明确映射,告诉用户:
`当前 tonghuashun-ifind-skill skill 没有稳定覆盖这个 iFinD 能力。`
如果 `smart-query` 和 iFinD 自然语言透传都失败,但 skill 里可能已经封过接口名,先执行:
```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-list
```
如果目录里已经有目标能力,对应执行:
```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-call --name history_quote --payload '{...}'
```
## 手动 API 调用
只有在你已经明确知道目标 endpoint 和 payload 的情况下,才使用:
```bash
python3 {baseDir}/scripts/ifind_cli.py api-call --endpoint /xxx --payload '{...}'
```
FILE:references/usage.md
# 使用说明
运行命令前,请先把 `{baseDir}` 替换成这个 skill 的目录。
如果要先判断“这件事到底能不能做、该走哪个入口”,先看:
- [能力矩阵](capability-matrix.md)
如果你要一份覆盖所有命令面的可抄完整例子,先看:
- [全面例子](full-examples.md)
## Skill 地址
- ClawHub / OpenClaw:
`https://clawhub.ai/etherstrings/tonghuashun-ifind`
- Hermes Agent GitHub skill 源:
`https://github.com/Etherstrings/tonghuashun-ifind-skill/tree/main/tonghuashun-ifind-skill`
补充:
- 当前发布版本:`0.5.1`
- Hermes 侧直接使用 GitHub skill 源,不再指向历史分支 PR
## 必须先完成 iFinD 鉴权
这个版本强制所有数据来自同花顺 iFinD。
- 没有可用 token 时,查询命令返回 `auth_required`
- `refresh_token` 续期失败时,不再继续查其它来源
- iFinD API 返回错误、权限不足或账号无对应接口权限时,直接返回 iFinD 错误
- 不使用腾讯财经、东方财富或其它公开源补数据
官方鉴权主路径:
1. 用户登录 iFinD 超级命令客户端或网页版账号详情,复制 `refresh_token`
2. skill 调用 `/get_access_token` 换取 `access_token`
3. 数据接口请求头带 `access_token`
Agent 对用户的标准引导:
```text
请先打开同花顺 iFinD 超级命令客户端,进入账号详情,复制 refresh_token。
如果你用网页端,可以打开:
https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails
登录后在账号详情里复制 refresh_token。复制后发给我,我会只用它换取 access_token 并缓存,不需要你的同花顺用户名或密码。
```
官方文档入口:
- iFinD HTTP 接口使用说明 / 鉴权说明:`https://quantapi.51ifind.com/gwstatic/static/ds_web/quantapi-web/help-center/deploy.html`
- iFinD Python HTTP 示例:`https://quantapi.51ifind.com/gwstatic/static/ds_web/quantapi-web/example.html`
- iFinD 网页版超级命令账号详情:`https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails`
## 安装给 Agent 的最短路径
如果你是安装 Agent,执行:
```bash
bash scripts/install_skill.sh
```
安装目标必须是:
```text
~/.openclaw/workspace/skills/tonghuashun-ifind-skill
```
安装后不要先写 API payload,先让用户完成鉴权,然后用 `smart-query`。
## 官方 refresh_token 鉴权
用户提供 `refresh_token` 后,Agent 执行:
```bash
python3 {baseDir}/scripts/ifind_cli.py auth-set-refresh-token \
--refresh-token "$IFIND_REFRESH_TOKEN"
```
## 手动注入双 token
```bash
python3 {baseDir}/scripts/ifind_cli.py auth-set-tokens \
--access-token "$IFIND_ACCESS_TOKEN" \
--refresh-token "$IFIND_REFRESH_TOKEN"
```
## 可选大模型路由
默认使用本地确定性路由。需要强化自然语言解析时,可以配置 OpenAI-compatible Chat Completions 服务:
```bash
export IFIND_ROUTE_LLM_ENABLED=1
export IFIND_ROUTE_LLM_API_KEY="$OPENAI_API_KEY"
export IFIND_ROUTE_LLM_MODEL="gpt-4o-mini"
```
可选变量:
- `IFIND_ROUTE_LLM_BASE_URL`
- `IFIND_ROUTE_LLM_TIMEOUT`
- `IFIND_ROUTE_LLM_MIN_CONFIDENCE`
大模型只负责输出 iFinD 路由计划;低置信度或模型调用失败时会回到本地规则。
## 原始 API 调用
```bash
python3 {baseDir}/scripts/ifind_cli.py api-call \
--endpoint /basic_data_service \
--payload '{"codes":"300750.SZ","indicators":"ths_close_price_stock","functionpara":{"Interval":"D","StartDate":"2025-01-01","EndDate":"2025-01-31"}}'
```
## 命名接口目录
如果你不想让 Agent 直接手写 endpoint 字符串,先看当前已封装目录:
```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-list
```
当前目录会返回一组带说明和样例 payload 的名字,例如:
- `basic_data`
- `smart_pick`
- `report_query`
- `date_sequence`
- `real_time_quote`
- `history_quote`
- `limit_up_screen`
- `leaderboard_screen`
- `fundamental_basic`
- `entity_profile`
- `capital_flow`
- `a_share_common_query`
- `generic_smart_query`
然后再按名字调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-call \
--name real_time_quote \
--payload '{"codes":"600519.SH,000300.SH","indicators":"open,high,low,latest,changeRatio,change,preClose,volume,amount,turnoverRatio,volumeRatio,amplitude,pb"}'
```
```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-call \
--name history_quote \
--payload '{"codes":"600004.SH","indicators":"open,close,high,low,volume","startdate":"2026-04-21","enddate":"2026-04-21"}'
```
## 常见查询主入口
优先让 Agent 用 `smart-query`,直接把用户的问题交给 skill 路由:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "看看贵州茅台现在股价"
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "看下宁德时代近一个月走势"
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "看一下大盘"
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "看看宁德时代基本面"
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "今天的A股涨停数据"
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "A股成交额榜前十"
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "贵州茅台主营业务是什么"
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "今天主力资金流入前十"
python3 {baseDir}/scripts/ifind_cli.py smart-query \
--query "筛一下新能源车产业链里市盈率低于30且近一个月放量的股票"
```
## 显式稳定命令
```bash
python3 {baseDir}/scripts/ifind_cli.py quote-realtime --symbol 600519
python3 {baseDir}/scripts/ifind_cli.py quote-history --symbol 300750 --days 30
python3 {baseDir}/scripts/ifind_cli.py market-snapshot
python3 {baseDir}/scripts/ifind_cli.py market-snapshot --symbol 沪深300
python3 {baseDir}/scripts/ifind_cli.py fundamental-basic --symbol 300750
```
说明:
- `quote-realtime`、`quote-history`、`market-snapshot` 只走 iFinD
- 涨停数据和 A 股榜单只走 iFinD `/smart_stock_picking`
- `fundamental-basic`、个股画像、资金流查询只走 iFinD
- 复杂自然语言查询会透传到 iFinD `/smart_stock_picking`
## 保留的原始薄封装
```bash
python3 {baseDir}/scripts/ifind_cli.py basic-data --payload '{"codes":"300750.SZ"}'
python3 {baseDir}/scripts/ifind_cli.py smart-pick --payload '{"conditions":[]}'
python3 {baseDir}/scripts/ifind_cli.py report-query --payload '{"codes":"300750.SZ"}'
python3 {baseDir}/scripts/ifind_cli.py date-sequence --payload '{"startdate":"2025-01-01","enddate":"2025-01-31"}'
```
## 失败处理规则
如果没有官方 `refresh_token`,就让用户先登录 iFinD 超级命令客户端或网页版账号详情复制,不要尝试其它数据源。
如果查询命令返回 `auth_required`:
1. 先运行 `auth-set-refresh-token`
2. 如果用户已经有双 token,再运行 `auth-set-tokens`
3. 不要用公开源替代
4. 鉴权成功后重试原查询
如果 iFinD API 返回业务错误:
1. 保留返回里的 `errorcode` 和 `errmsg`
2. 告诉用户这是 iFinD 调用失败或权限问题
3. 不要自动切换到非同花顺数据源
如果 `smart-query` 返回需要手动查接口:
1. 先读 `references/routing.md`
2. 再看 `references/use-cases.md` 里是否已有类似问法
3. 再决定是否使用 `api-call`
4. 如果 `endpoint-list` 里已有合适名字,优先 `endpoint-call`
5. 如果文档里也找不到合适接口,就明确告诉用户当前 skill 未覆盖该 iFinD 能力
FILE:references/use-cases.md
# 常见 Use Cases
这份文件是给 Agent 看的快速案例库。
原则:
- 先找和用户问题最接近的案例
- 先用 [能力矩阵](capability-matrix.md) 判断当前问题属于哪一类能力
- 所有数据查询都必须先完成 iFinD 鉴权
- 能用 `smart-query` 时不要先手写 `api-call`
- 如果案例和当前请求明显不匹配,就回到 `routing.md`
## 1. 个股最新价
用户问法:
- `看看贵州茅台现在股价`
- `宁德时代最新价`
- `查一下 600519 行情`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "看看贵州茅台现在股价"
```
预期路由:
- intent: `quote_realtime`
- endpoint: `/real_time_quotation`
- if iFinD fail: 返回 iFinD 错误,不使用其它数据源
## 2. 个股近一段时间走势
用户问法:
- `看下宁德时代近一个月走势`
- `贵州茅台最近一周表现`
- `看 300750 历史行情`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "看下宁德时代近一个月走势"
```
预期路由:
- intent: `quote_history`
- endpoint: `/cmd_history_quotation`
- if iFinD fail: 返回 iFinD 错误,不使用其它数据源
## 3. 大盘或指数快照
用户问法:
- `看一下大盘`
- `看看指数`
- `沪深300现在怎么样`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "看一下大盘"
```
预期路由:
- intent: `market_snapshot`
- endpoint: `/real_time_quotation`
- if iFinD fail: 返回 iFinD 错误,不使用其它数据源
默认指数包:
- 上证指数
- 深证成指
- 创业板指
- 沪深300
## 4. 基础财务指标
用户问法:
- `看看宁德时代基本面`
- `贵州茅台估值怎么样`
- `看下 300750 的财务和市盈率`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "看看宁德时代基本面"
```
预期路由:
- intent: `fundamental_basic`
- endpoint: `/smart_stock_picking`
- if iFinD fail: 返回 iFinD 错误或权限问题
## 5. 涨停数据
用户问法:
- `今天的A股涨停数据`
- `今日涨停`
- `涨停板`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "今天的A股涨停数据"
```
预期路由:
- intent: `limit_up_screen`
- endpoint: `/smart_stock_picking`
- if iFinD fail: 返回 iFinD 错误,不使用其它数据源
## 6. A 股榜单
用户问法:
- `A股成交额榜前十`
- `今日涨幅榜`
- `跌幅榜前二十`
- `量比榜`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "A股成交额榜前十"
```
预期路由:
- intent: `leaderboard_screen`
- endpoint: `/smart_stock_picking`
- if iFinD fail: 返回 iFinD 错误,不使用其它数据源
## 7. 个股画像 / 主营业务
用户问法:
- `贵州茅台主营业务是什么`
- `宁德时代公司简介`
- `这家公司是做什么的`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "贵州茅台主营业务是什么"
```
预期路由:
- intent: `entity_profile`
- endpoint: `/smart_stock_picking`
- if iFinD fail: 返回 iFinD 错误或权限问题
## 8. 资金流
用户问法:
- `今天主力资金流入前十`
- `主力资金净流入排行`
- `宁德时代资金流向`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "今天主力资金流入前十"
```
预期路由:
- intent: `capital_flow`
- endpoint: `/smart_stock_picking`
- if iFinD fail: 返回 iFinD 错误或权限问题
## 9. A 股常见数据查询
用户问法:
- `贵州茅台最近公告`
- `贵州茅台分红记录`
- `贵州茅台龙虎榜`
- `宁德时代融资余额和北向持股情况`
- `宁德时代限售解禁安排`
- `宁德时代所属概念和产业链`
- `明天A股有哪些新股申购`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "宁德时代融资余额和北向持股情况"
```
预期路由:
- intent: `generic_smart_query`
- endpoint: `/smart_stock_picking`
- 默认把用户原问题作为 `searchstring`;口语“有啥/怎么样/啥消息”会先改写成 iFinD 更稳定的正式查询词
- if iFinD fail: 返回 iFinD 错误,不使用其它数据源
## 10. 交易日 / 休市日
用户问法:
- `下一个交易日是什么时候`
- `下个交易日是哪天`
- `明天开不开盘`
- `今天A股休市吗`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "下个交易日是哪天"
```
预期路由:
- intent: `trading_calendar`
- endpoint: `/date_sequence`
- 返回数据中的 `time` 字段为 iFinD 交易日序列
处理原则:
- 不要把这类问法误交给 `/smart_stock_picking`
- 不要让 Agent 自己猜休市日;以 iFinD `/date_sequence` 返回的 `time` 为准
## 11. 复杂自然语言筛选
用户问法:
- `筛一下新能源车产业链里市盈率低于30且近一个月放量的股票`
- `查一下贵州茅台近三年营收和毛利率`
- `找一下今天主力资金流入靠前的半导体股票`
建议调用:
```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "筛一下新能源车产业链里市盈率低于30且近一个月放量的股票"
```
预期路由:
- intent: `generic_smart_query`
- endpoint: `/smart_stock_picking`
- 直接把用户原问题作为 `searchstring`
处理原则:
- 不要让 Agent 先手写 `api-call`
- 先让 iFinD 自然语言能力处理
- iFinD 返回失败后,再考虑 `endpoint-list` 或明确说明当前未覆盖
## 12. 不要乱猜的请求
用户问法:
- `帮我找贵州茅台公告PDF下载链接并按日期排序`
- `把所有年报原文下载地址列出来`
- `找公告附件全文`
处理方式:
1. 先运行 `smart-query`
2. 如果返回 `manual_api_lookup_required`,就读 `routing.md`
3. 如果仍没有明确接口,就直接告诉用户:
`当前 tonghuashun-ifind-skill skill 没有稳定覆盖这个 iFinD 能力。`
FILE:scripts/ifind_cli.py
from __future__ import annotations
from datetime import date
from datetime import datetime
from datetime import timedelta
from datetime import timezone
import argparse
import json
from pathlib import Path
import re
import sys
_RUNTIME_DIR = Path(__file__).resolve().parent / "runtime"
if _RUNTIME_DIR.is_dir():
runtime_path = str(_RUNTIME_DIR)
if runtime_path not in sys.path:
sys.path.insert(0, runtime_path)
DEFAULT_BASE_URL = "https://quantapi.51ifind.com/api/v1"
def main(argv: list[str] | None = None) -> int:
result = run_command(sys.argv[1:] if argv is None else argv)
print(json.dumps(result, ensure_ascii=False))
return 0 if result["ok"] else 1
def run_command(argv: list[str]) -> dict[str, object]:
from tonghuashun_ifind_skill.client import build_envelope
parser = _build_parser()
try:
args = parser.parse_args(argv)
except SystemExit:
return build_envelope(
ok=False,
endpoint="/cli",
token_source="cli",
error_type="invalid_request",
error_message="invalid arguments",
)
state_path = (
Path(args.state_path).expanduser()
if args.state_path
else _default_state_path()
)
try:
if args.command == "auth-set-tokens":
return _handle_auth_set_tokens(args, state_path)
if args.command == "auth-set-refresh-token":
return _handle_auth_set_refresh_token(args, state_path)
if args.command == "endpoint-list":
return _handle_endpoint_list()
if args.command in {
"api-call",
"endpoint-call",
"basic-data",
"smart-pick",
"report-query",
"date-sequence",
}:
return _handle_api_command(args, state_path)
if args.command in {
"smart-query",
"quote-realtime",
"quote-history",
"market-snapshot",
"fundamental-basic",
}:
return _handle_routed_query_command(args, state_path)
except Exception as exc:
return build_envelope(
ok=False,
endpoint=_command_endpoint(args),
token_source="cli",
error_type="runtime_failed",
error_message=_sanitize_exception(exc),
)
return build_envelope(
ok=False,
endpoint="/cli",
token_source="cli",
error_type="invalid_request",
error_message="unknown command",
)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="ifind-cli")
parser.add_argument(
"--state-path",
default=None,
help="Path to token state storage",
)
subparsers = parser.add_subparsers(dest="command", required=True)
auth_set = subparsers.add_parser("auth-set-tokens")
auth_set.add_argument("--access-token", required=True)
auth_set.add_argument("--refresh-token", required=True)
auth_set.add_argument("--expires-at", default=None)
auth_refresh = subparsers.add_parser("auth-set-refresh-token")
auth_refresh.add_argument("--refresh-token", required=True)
auth_refresh.add_argument("--base-url", default=DEFAULT_BASE_URL)
api_common = argparse.ArgumentParser(add_help=False)
api_common.add_argument("--base-url", default=DEFAULT_BASE_URL)
api_common.add_argument("--payload", default="{}")
api_call = subparsers.add_parser("api-call", parents=[api_common])
api_call.add_argument("--endpoint", required=True)
subparsers.add_parser("endpoint-list")
endpoint_call = subparsers.add_parser("endpoint-call", parents=[api_common])
endpoint_call.add_argument("--name", required=True)
subparsers.add_parser("basic-data", parents=[api_common])
subparsers.add_parser("smart-pick", parents=[api_common])
subparsers.add_parser("report-query", parents=[api_common])
subparsers.add_parser("date-sequence", parents=[api_common])
smart_query = subparsers.add_parser("smart-query", parents=[api_common])
smart_query.add_argument("--query", required=True)
quote_realtime = subparsers.add_parser("quote-realtime", parents=[api_common])
quote_realtime.add_argument("--symbol", required=True)
quote_history = subparsers.add_parser("quote-history", parents=[api_common])
quote_history.add_argument("--symbol", required=True)
quote_history.add_argument("--start-date", default=None)
quote_history.add_argument("--end-date", default=None)
quote_history.add_argument("--days", type=int, default=30)
market_snapshot = subparsers.add_parser("market-snapshot", parents=[api_common])
market_snapshot.add_argument("--symbol", default=None)
fundamental_basic = subparsers.add_parser("fundamental-basic", parents=[api_common])
fundamental_basic.add_argument("--symbol", required=True)
return parser
def _handle_auth_set_tokens(
args: argparse.Namespace,
state_path: Path,
) -> dict[str, object]:
from tonghuashun_ifind_skill.client import build_envelope
from tonghuashun_ifind_skill.models import TokenBundle
from tonghuashun_ifind_skill.state import TokenStateStore
expires_at = args.expires_at or _default_expiry()
bundle = TokenBundle(
access_token=args.access_token,
refresh_token=args.refresh_token,
expires_at=expires_at,
)
TokenStateStore(state_path).save(bundle)
return build_envelope(
ok=True,
endpoint="/auth/set-tokens",
token_source="manual",
data={"stored": True, "expires_at": expires_at},
)
def _handle_auth_set_refresh_token(
args: argparse.Namespace,
state_path: Path,
) -> dict[str, object]:
from tonghuashun_ifind_skill.auth import exchange_refresh_token
from tonghuashun_ifind_skill.client import build_envelope
from tonghuashun_ifind_skill.state import TokenStateStore
bundle = exchange_refresh_token(
args.refresh_token,
base_url=args.base_url,
)
TokenStateStore(state_path).save(bundle)
return build_envelope(
ok=True,
endpoint="/auth/set-refresh-token",
token_source="refresh",
data={"stored": True, "expires_at": bundle.expires_at},
)
def _handle_api_command(
args: argparse.Namespace,
state_path: Path,
) -> dict[str, object]:
from tonghuashun_ifind_skill.client import IFindClient
from tonghuashun_ifind_skill.client import build_envelope
from tonghuashun_ifind_skill.endpoint_catalog import get_endpoint_spec
payload = _parse_payload(args.payload)
if args.command == "endpoint-call":
try:
spec = get_endpoint_spec(args.name)
except ValueError as exc:
return build_envelope(
ok=False,
endpoint="/endpoint_catalog",
token_source="cli",
error_type="invalid_request",
error_message=str(exc),
)
auth = _build_auth_manager(
state_path=state_path,
base_url=args.base_url,
)
bundle, token_source = auth.resolve_tokens()
client = IFindClient(base_url=args.base_url)
if args.command == "api-call":
return client.api_call(
args.endpoint,
payload,
bundle.access_token,
token_source,
)
if args.command == "endpoint-call":
return client.call_named_endpoint(
spec.name,
payload,
bundle.access_token,
token_source,
)
if args.command == "basic-data":
return client.basic_data(payload, bundle.access_token, token_source)
if args.command == "smart-pick":
return client.smart_stock_picking(payload, bundle.access_token, token_source)
if args.command == "report-query":
return client.report_query(payload, bundle.access_token, token_source)
if args.command == "date-sequence":
return client.date_sequence(payload, bundle.access_token, token_source)
return build_envelope(
ok=False,
endpoint=_command_endpoint(args),
token_source="cli",
error_type="invalid_request",
error_message="unknown api command",
)
def _handle_endpoint_list() -> dict[str, object]:
from tonghuashun_ifind_skill.client import build_envelope
from tonghuashun_ifind_skill.endpoint_catalog import list_endpoint_specs
return build_envelope(
ok=True,
endpoint="/endpoint_catalog",
token_source="cli",
data={
"endpoints": [spec.to_dict() for spec in list_endpoint_specs()],
},
)
def _handle_routed_query_command(
args: argparse.Namespace,
state_path: Path,
) -> dict[str, object]:
from tonghuashun_ifind_skill.client import IFindClient
from tonghuashun_ifind_skill.client import build_envelope
from tonghuashun_ifind_skill.llm_routing import build_llm_route_plan
from tonghuashun_ifind_skill.routing import build_history_plan
from tonghuashun_ifind_skill.routing import build_market_snapshot_plan
from tonghuashun_ifind_skill.routing import build_realtime_plan
from tonghuashun_ifind_skill.routing import build_route_plan
from tonghuashun_ifind_skill.routing import extract_entity_from_search_payload
from tonghuashun_ifind_skill.routing import resolve_common_index_entity
if args.command == "smart-query" and _is_blank_or_punctuation_query(args.query):
return build_envelope(
ok=False,
endpoint="/manual_lookup",
token_source="cli",
error_type="manual_api_lookup_required",
error_message="请输入明确的股票、指数、指标、日期或筛选条件;空白或纯标点无法路由到 iFinD。",
data={
"intent": "manual_lookup",
"note": "blank_or_punctuation_query",
},
)
auth = _build_auth_manager(
state_path=state_path,
base_url=args.base_url,
)
client = IFindClient(base_url=args.base_url)
auth_cache: dict[str, object] = {}
def ensure_auth() -> tuple[object, str]:
bundle = auth_cache.get("bundle")
token_source = auth_cache.get("token_source")
if bundle is not None and isinstance(token_source, str):
return bundle, token_source
resolved_bundle, resolved_token_source = auth.resolve_tokens()
auth_cache["bundle"] = resolved_bundle
auth_cache["token_source"] = resolved_token_source
return resolved_bundle, resolved_token_source
try:
bundle, token_source = ensure_auth()
except Exception as exc:
return build_envelope(
ok=False,
endpoint=_command_endpoint(args),
token_source="cli",
error_type="auth_required",
error_message=_auth_required_message(exc),
)
def entity_lookup(text: str):
common_index = resolve_common_index_entity(text)
if common_index is not None:
return common_index
payload = {
"searchstring": f"{text} 股票代码 股票简称",
"searchtype": "stock",
}
result = client.smart_stock_picking(payload, bundle.access_token, token_source)
if not result.get("ok"):
return None
raw_payload = result.get("data")
if not isinstance(raw_payload, dict):
return None
entity = extract_entity_from_search_payload(text, raw_payload)
if entity is not None:
return entity
return None
if args.command == "smart-query":
plan = build_llm_route_plan(
args.query,
entity_lookup=entity_lookup,
today=date.today(),
)
if plan is None:
plan = build_route_plan(
args.query,
entity_lookup=entity_lookup,
today=date.today(),
)
elif args.command == "quote-realtime":
plan = build_route_plan(
f"{args.symbol} 最新价",
entity_lookup=entity_lookup,
today=date.today(),
)
elif args.command == "quote-history":
plan = build_route_plan(
f"{args.symbol} 近{args.days}天走势",
entity_lookup=entity_lookup,
today=date.today(),
)
if plan.intent == "quote_history" and plan.entity is not None:
plan = build_history_plan(
plan.entity,
query=f"{args.symbol} 近{args.days}天走势",
today=date.today(),
start_date=args.start_date,
end_date=args.end_date,
)
elif args.command == "market-snapshot":
plan = build_market_snapshot_plan(args.symbol)
elif args.command == "fundamental-basic":
plan = build_route_plan(
f"{args.symbol} 基本面",
entity_lookup=entity_lookup,
today=date.today(),
)
else:
return build_envelope(
ok=False,
endpoint="/cli",
token_source="cli",
error_type="invalid_request",
error_message="unknown routed command",
)
if plan.intent == "manual_lookup":
return build_envelope(
ok=False,
endpoint="/manual_lookup",
token_source="cli",
error_type="manual_api_lookup_required",
error_message=plan.note or "manual lookup required",
data={
"intent": plan.intent,
"note": plan.note,
},
)
if args.command == "fundamental-basic" or plan.intent == "fundamental_basic":
return _execute_fundamental_plan(
client=client,
access_token=bundle.access_token,
token_source=token_source,
plan=plan,
)
result = client.api_call(
plan.endpoint or "/",
plan.payload or {},
bundle.access_token,
token_source,
)
return _attach_route_metadata(result, plan)
def _build_auth_manager(
*,
state_path: Path,
base_url: str,
) -> "AuthManager":
from tonghuashun_ifind_skill.auth import AuthManager
from tonghuashun_ifind_skill.auth import exchange_refresh_token
from tonghuashun_ifind_skill.models import TokenBundle
def refresh_exchange(refresh_token: str) -> TokenBundle:
return exchange_refresh_token(
refresh_token,
base_url=base_url,
)
return AuthManager.create(
state_path=state_path,
refresh_exchange=refresh_exchange,
)
def _parse_payload(payload: str) -> dict[str, object]:
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise ValueError("payload must be valid JSON") from exc
if not isinstance(data, dict):
raise ValueError("payload must be a JSON object")
return data
def _default_state_path() -> Path:
return Path.home() / ".openclaw" / "tonghuashun-ifind-skill" / "token_state.json"
def _default_expiry() -> str:
expires_at = datetime.now(timezone.utc) + timedelta(minutes=30)
return expires_at.replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _sanitize_exception(exc: Exception) -> str:
return f"request failed: {exc.__class__.__name__}"
def _is_blank_or_punctuation_query(query: str) -> bool:
return not re.search(r"[A-Za-z0-9\u4e00-\u9fff]", query or "")
def _auth_required_message(exc: Exception) -> str:
return (
"iFinD authentication is required before querying data. "
"Ask the user to open the iFinD Super Command client account details page, "
"or open https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/"
"super-command-web/index.html#/AccountDetails, log in, copy refresh_token, "
"then run auth-set-refresh-token. Do not ask for the iFinD username or "
"password. If the user already has both tokens, run auth-set-tokens. Detail: "
f"{_sanitize_exception(exc)}"
)
def _attach_route_metadata(
result: dict[str, object],
plan,
*,
response_data: object | None = None,
note: str | None = None,
provider: dict[str, object] | None = None,
) -> dict[str, object]:
effective_response = result.get("data") if response_data is None else response_data
effective_provider = provider
if effective_provider is None and isinstance(effective_response, dict):
maybe_provider = effective_response.get("provider")
if isinstance(maybe_provider, dict):
effective_provider = maybe_provider
effective_response = {
key: value
for key, value in effective_response.items()
if key != "provider"
}
result["data"] = {
"intent": plan.intent,
"entity": None if plan.entity is None else {
"raw": plan.entity.raw,
"symbol": plan.entity.symbol,
"name": plan.entity.name,
"entity_type": plan.entity.entity_type,
},
"request": {"payload": plan.payload},
"response": effective_response,
"note": note if note is not None else plan.note,
}
if effective_provider is not None:
result["data"]["provider"] = effective_provider
return result
def _execute_fundamental_plan(
*,
client,
access_token: str,
token_source: str,
plan,
) -> dict[str, object]:
from tonghuashun_ifind_skill.client import build_envelope
payload = plan.payload or {}
searchstrings = payload.get("searchstrings")
searchtype = payload.get("searchtype", "stock")
if not isinstance(searchstrings, list) or not searchstrings:
return build_envelope(
ok=False,
endpoint="/smart_stock_picking",
token_source=token_source,
error_type="invalid_request",
error_message="missing searchstrings for fundamental route",
)
labels = ("financials", "valuation", "forecast")
results: dict[str, object] = {}
partial_failures: list[str] = []
any_success = False
errors: dict[str, object] = {}
for label, searchstring in zip(labels, searchstrings):
result = client.smart_stock_picking(
{"searchstring": searchstring, "searchtype": searchtype},
access_token,
token_source,
)
if result.get("ok"):
any_success = True
results[label] = result.get("data")
else:
partial_failures.append(label)
errors[label] = result.get("error")
if not any_success:
return build_envelope(
ok=False,
endpoint="/smart_stock_picking",
token_source=token_source,
error_type="api_failed",
error_message="all fundamental queries failed",
data={
"intent": plan.intent,
"entity": None if plan.entity is None else {
"raw": plan.entity.raw,
"symbol": plan.entity.symbol,
"name": plan.entity.name,
"entity_type": plan.entity.entity_type,
},
"request": {"payload": plan.payload},
"partial_failures": partial_failures,
"errors": errors,
},
)
return build_envelope(
ok=True,
endpoint="/smart_stock_picking",
token_source=token_source,
data={
"intent": plan.intent,
"entity": None if plan.entity is None else {
"raw": plan.entity.raw,
"symbol": plan.entity.symbol,
"name": plan.entity.name,
"entity_type": plan.entity.entity_type,
},
"request": {"payload": plan.payload},
"results": results,
"partial_failures": partial_failures,
"errors": errors,
},
)
def _command_endpoint(args: argparse.Namespace) -> str:
if args.command == "auth-set-refresh-token":
return "/auth/set-refresh-token"
if args.command == "auth-set-tokens":
return "/auth/set-tokens"
if args.command == "api-call":
endpoint = getattr(args, "endpoint", "")
return endpoint if endpoint else "/"
if args.command == "endpoint-list":
return "/endpoint_catalog"
if args.command == "endpoint-call":
return "/endpoint_catalog"
if args.command == "basic-data":
return "/basic_data_service"
if args.command == "smart-pick":
return "/smart_stock_picking"
if args.command == "report-query":
return "/report_query"
if args.command == "date-sequence":
return "/date_sequence"
if args.command == "smart-query":
return "/smart_query"
if args.command == "quote-realtime":
return "/real_time_quotation"
if args.command == "quote-history":
return "/cmd_history_quotation"
if args.command == "market-snapshot":
return "/real_time_quotation"
if args.command == "fundamental-basic":
return "/smart_stock_picking"
return "/cli"
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/runtime/tonghuashun_ifind_skill/__init__.py
"""Runtime package for the Tonghuashun iFind skill."""
FILE:scripts/runtime/tonghuashun_ifind_skill/auth.py
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from pathlib import Path
from typing import Literal
import requests
from tonghuashun_ifind_skill.models import TokenBundle
from tonghuashun_ifind_skill.models import format_timestamp
from tonghuashun_ifind_skill.state import TokenStateStore
class AuthManager:
def __init__(
self,
*,
state_store: TokenStateStore,
refresh_exchange: Callable[[str], TokenBundle],
) -> None:
self.state_store = state_store
self.refresh_exchange = refresh_exchange
@classmethod
def for_test(
cls,
*,
state_path: Path,
refresh_exchange: Callable[[str], TokenBundle],
) -> "AuthManager":
return cls(
state_store=TokenStateStore(state_path),
refresh_exchange=refresh_exchange,
)
@classmethod
def create(
cls,
*,
state_path: Path,
refresh_exchange: Callable[[str], TokenBundle],
) -> "AuthManager":
return cls(
state_store=TokenStateStore(state_path),
refresh_exchange=refresh_exchange,
)
def resolve_tokens(
self,
) -> tuple[TokenBundle, Literal["cache", "refresh"]]:
bundle = self.state_store.load()
if bundle is not None and not bundle.is_stale():
return bundle, "cache"
if bundle is None:
raise RuntimeError(
"missing iFinD token state; run auth-set-refresh-token with "
"the refresh_token from iFinD Super Command"
)
if not bundle.refresh_token:
raise RuntimeError(
"cached iFinD token state has no refresh_token; run "
"auth-set-refresh-token"
)
try:
refreshed = self.refresh_exchange(bundle.refresh_token)
except Exception:
raise RuntimeError(
"failed to exchange iFinD refresh_token for access_token"
)
self.state_store.save(refreshed)
return refreshed, "refresh"
def exchange_refresh_token(
refresh_token: str,
*,
base_url: str,
timeout: float = 10.0,
now: Callable[[], datetime] | None = None,
) -> TokenBundle:
response = requests.post(
f"{base_url.rstrip('/')}/get_access_token",
json={},
headers={"Content-Type": "application/json", "refresh_token": refresh_token},
timeout=timeout,
)
response.raise_for_status()
access_token, expires_in = _parse_refresh_payload(response.json())
return TokenBundle(
access_token=access_token,
refresh_token=refresh_token,
expires_at=_resolve_refresh_expiry(expires_in, now=now),
)
def _parse_refresh_payload(payload: object) -> tuple[str, int]:
if not isinstance(payload, dict):
raise ValueError("iFinD auth response must be a JSON object")
data = payload.get("data")
if not isinstance(data, dict):
raise ValueError("iFinD auth response missing data object")
access_token = data.get("access_token")
if not isinstance(access_token, str) or not access_token:
raise ValueError("iFinD auth response missing access_token")
expires_in_raw = data.get("expires_in", 0)
try:
expires_in = int(expires_in_raw)
except (TypeError, ValueError):
expires_in = 0
return access_token, expires_in
def _resolve_refresh_expiry(
expires_in: int,
*,
now: Callable[[], datetime] | None = None,
) -> str:
current = now() if now is not None else datetime.now(timezone.utc)
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
else:
current = current.astimezone(timezone.utc)
expires_at = current + timedelta(seconds=max(expires_in - 30, 0))
return format_timestamp(expires_at)
FILE:scripts/runtime/tonghuashun_ifind_skill/client.py
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime
from datetime import timezone
from typing import Any
from tonghuashun_ifind_skill.endpoint_catalog import get_endpoint_spec
from tonghuashun_ifind_skill.models import ErrorPayload
from tonghuashun_ifind_skill.models import ResponseEnvelope
from tonghuashun_ifind_skill.models import ResponseMeta
from tonghuashun_ifind_skill.models import format_timestamp
class IFindClient:
def __init__(
self,
*,
base_url: str,
session: Any | None = None,
timeout: float = 30.0,
now: Callable[[], datetime] | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.session = session if session is not None else self._default_session()
self.timeout = timeout
self._now = now or (lambda: datetime.now(timezone.utc))
def api_call(
self,
endpoint: str,
payload: dict[str, object],
access_token: str,
token_source: str,
) -> dict[str, object]:
normalized_endpoint = self._normalize_endpoint(endpoint)
url = f"{self.base_url}{normalized_endpoint}"
headers = {"Content-Type": "application/json", "access_token": access_token}
timestamp = format_timestamp(self._now())
try:
response = self.session.post(
url,
json=payload,
headers=headers,
timeout=self.timeout,
)
except Exception as exc:
envelope = ResponseEnvelope(
ok=False,
endpoint=normalized_endpoint,
token_source=token_source,
data=None,
error=ErrorPayload(
type="runtime_failed",
message=self._sanitize_exception_message(exc),
),
meta=ResponseMeta(timestamp=timestamp),
)
return envelope.to_dict()
status_code = getattr(response, "status_code", 200)
try:
body = response.json()
except Exception as exc:
error_message = str(exc) if status_code < 400 else f"http error {status_code}"
envelope = ResponseEnvelope(
ok=False,
endpoint=normalized_endpoint,
token_source=token_source,
data=None,
error=ErrorPayload(type="runtime_failed", message=error_message),
meta=ResponseMeta(timestamp=timestamp),
)
return envelope.to_dict()
error = self._extract_error(body)
if status_code >= 400:
if error is None:
error = ErrorPayload(
type="runtime_failed",
message=f"http error {status_code}",
)
envelope = ResponseEnvelope(
ok=False,
endpoint=normalized_endpoint,
token_source=token_source,
data=None,
error=error,
meta=ResponseMeta(timestamp=timestamp),
)
return envelope.to_dict()
envelope = ResponseEnvelope(
ok=error is None,
endpoint=normalized_endpoint,
token_source=token_source,
data=None if error is not None else body,
error=error,
meta=ResponseMeta(timestamp=timestamp),
)
return envelope.to_dict()
def basic_data(
self,
payload: dict[str, object],
access_token: str,
token_source: str,
) -> dict[str, object]:
return self.api_call("/basic_data_service", payload, access_token, token_source)
def smart_stock_picking(
self,
payload: dict[str, object],
access_token: str,
token_source: str,
) -> dict[str, object]:
return self.api_call(
"/smart_stock_picking",
payload,
access_token,
token_source,
)
def report_query(
self,
payload: dict[str, object],
access_token: str,
token_source: str,
) -> dict[str, object]:
return self.api_call("/report_query", payload, access_token, token_source)
def date_sequence(
self,
payload: dict[str, object],
access_token: str,
token_source: str,
) -> dict[str, object]:
return self.api_call(
"/date_sequence",
payload,
access_token,
token_source,
)
def call_named_endpoint(
self,
name: str,
payload: dict[str, object],
access_token: str,
token_source: str,
) -> dict[str, object]:
spec = get_endpoint_spec(name)
return self.api_call(
spec.endpoint,
payload,
access_token,
token_source,
)
@staticmethod
def _normalize_endpoint(endpoint: str) -> str:
if not endpoint:
return "/"
return endpoint if endpoint.startswith("/") else f"/{endpoint}"
@staticmethod
def _default_session() -> Any:
try:
import requests
except ModuleNotFoundError as exc: # pragma: no cover - runtime dependency guard
raise RuntimeError("requests is required for default sessions") from exc
return requests.Session()
@staticmethod
def _extract_error(payload: object) -> ErrorPayload | None:
if not isinstance(payload, dict):
return None
if "errorcode" not in payload:
return None
errorcode = payload.get("errorcode")
errmsg = payload.get("errmsg")
if errorcode in (0, "0", None):
return None
message = errmsg if isinstance(errmsg, str) else "iFinD business error"
return ErrorPayload(
type="api_failed",
message=message,
errorcode=errorcode,
errmsg=errmsg if isinstance(errmsg, str) else None,
)
@staticmethod
def _sanitize_exception_message(exc: Exception) -> str:
exc_name = exc.__class__.__name__
return f"request failed: {exc_name}"
def build_envelope(
*,
ok: bool,
endpoint: str,
token_source: str,
data: object | None = None,
error_type: str | None = None,
error_message: str | None = None,
errorcode: int | str | None = None,
errmsg: str | None = None,
now: Callable[[], datetime] | datetime | None = None,
) -> dict[str, object]:
if callable(now):
timestamp = format_timestamp(now())
elif isinstance(now, datetime):
timestamp = format_timestamp(now)
else:
timestamp = format_timestamp()
error = None
if not ok:
error = ErrorPayload(
type=error_type or "runtime_failed",
message=error_message or "request failed",
errorcode=errorcode,
errmsg=errmsg,
)
envelope = ResponseEnvelope(
ok=ok,
endpoint=endpoint,
token_source=token_source,
data=data,
error=error,
meta=ResponseMeta(timestamp=timestamp),
)
return envelope.to_dict()
FILE:scripts/runtime/tonghuashun_ifind_skill/endpoint_catalog.py
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class EndpointSpec:
name: str
endpoint: str
category: str
description: str
example_payload: dict[str, object]
requires_ifind_auth: bool = True
notes: tuple[str, ...] = ()
def to_dict(self) -> dict[str, object]:
return {
"name": self.name,
"endpoint": self.endpoint,
"category": self.category,
"description": self.description,
"example_payload": self.example_payload,
"requires_ifind_auth": self.requires_ifind_auth,
"notes": list(self.notes),
}
_ENDPOINT_SPECS = {
"basic_data": EndpointSpec(
name="basic_data",
endpoint="/basic_data_service",
category="core_api",
description="基础指标查询,适合直接按代码和指标名取数。",
example_payload={
"codes": "300750.SZ",
"indicators": "ths_close_price_stock",
},
),
"smart_pick": EndpointSpec(
name="smart_pick",
endpoint="/smart_stock_picking",
category="core_api",
description="自然语言选股与金融问答透传接口,涨停、榜单、画像、资金流都基于它。",
example_payload={
"searchstring": "今天的A股涨停数据",
"searchtype": "stock",
},
notes=(
"所有数据来自 iFinD;使用前必须完成鉴权。",
"涨停、榜单、画像和资金流都通过 iFinD smart_stock_picking 透传。",
),
),
"report_query": EndpointSpec(
name="report_query",
endpoint="/report_query",
category="core_api",
description="研报或报告类查询透传接口。",
example_payload={"codes": "300750.SZ"},
),
"date_sequence": EndpointSpec(
name="date_sequence",
endpoint="/date_sequence",
category="core_api",
description="日期序列、交易日历等时间轴能力透传接口。",
example_payload={
"codes": "000001.SH",
"startdate": "2026-04-01",
"enddate": "2026-04-30",
"functionpara": {"Days": "Tradedays", "Fill": "Omit"},
"indipara": [
{
"indicator": "ths_close_price_stock",
"indiparams": ["", "100", ""],
}
],
},
),
"real_time_quote": EndpointSpec(
name="real_time_quote",
endpoint="/real_time_quotation",
category="market_data",
description="实时行情原始接口,适合单股或指数快照。",
example_payload={
"codes": "600519.SH,000300.SH",
"indicators": (
"open,high,low,latest,changeRatio,change,preClose,volume,"
"amount,turnoverRatio,volumeRatio,amplitude,pb"
),
},
notes=("所有行情数据来自 iFinD;不再回退到公开行情源。",),
),
"history_quote": EndpointSpec(
name="history_quote",
endpoint="/cmd_history_quotation",
category="market_data",
description="历史行情原始接口,适合日线区间查询。",
example_payload={
"codes": "600004.SH",
"indicators": "open,close,high,low,volume",
"startdate": "2026-04-21",
"enddate": "2026-04-21",
},
notes=("所有历史行情数据来自 iFinD;不再回退到公开行情源。",),
),
"limit_up_screen": EndpointSpec(
name="limit_up_screen",
endpoint="/smart_stock_picking",
category="routed_capability",
description="涨停池能力别名;推荐优先用 smart-query。",
example_payload={
"searchstring": "今天的A股涨停数据",
"searchtype": "stock",
},
notes=("所有涨停数据来自 iFinD smart_stock_picking。",),
),
"leaderboard_screen": EndpointSpec(
name="leaderboard_screen",
endpoint="/smart_stock_picking",
category="routed_capability",
description="榜单能力别名;适合成交额榜、涨跌幅榜、换手率榜、振幅榜、量比榜。",
example_payload={
"searchstring": "A股成交额榜前十",
"searchtype": "stock",
},
notes=("所有榜单数据来自 iFinD smart_stock_picking。",),
),
"fundamental_basic": EndpointSpec(
name="fundamental_basic",
endpoint="/smart_stock_picking",
category="routed_capability",
description="基本面能力别名;推荐优先用 smart-query 或 fundamental-basic。",
example_payload={
"searchstring": "宁德时代基本面",
"searchtype": "stock",
},
notes=("所有基本面数据来自 iFinD smart_stock_picking。",),
),
"entity_profile": EndpointSpec(
name="entity_profile",
endpoint="/smart_stock_picking",
category="routed_capability",
description="公司简介、主营业务等画像能力别名。",
example_payload={
"searchstring": "贵州茅台主营业务是什么",
"searchtype": "stock",
},
notes=("所有画像数据来自 iFinD smart_stock_picking。",),
),
"capital_flow": EndpointSpec(
name="capital_flow",
endpoint="/smart_stock_picking",
category="routed_capability",
description="资金流问法能力别名。",
example_payload={
"searchstring": "今天主力资金流入前十",
"searchtype": "stock",
},
notes=("所有资金流数据来自 iFinD smart_stock_picking。",),
),
"a_share_common_query": EndpointSpec(
name="a_share_common_query",
endpoint="/smart_stock_picking",
category="routed_capability",
description="A 股用户常见自然语言查询入口;覆盖公告、研报、龙虎榜、两融、北向、股东、持仓、分红、解禁、停复牌、概念板块、新股和交易日等问法。",
example_payload={
"searchstring": "贵州茅台最近分红、十大股东和北向持股情况",
"searchtype": "stock",
},
notes=(
"这些问法保留用户原始自然语言交给 iFinD smart_stock_picking。",
"如果 iFinD 返回权限不足或无法处理,直接反馈 iFinD 失败,不切换公开源。",
),
),
"generic_smart_query": EndpointSpec(
name="generic_smart_query",
endpoint="/smart_stock_picking",
category="routed_capability",
description="自然语言泛化查询入口;当本地规则没有稳定命中特定能力时,将用户原问题交给 iFinD smart_stock_picking。",
example_payload={
"searchstring": "筛一下新能源车产业链里市盈率低于30且近一个月放量的股票",
"searchtype": "stock",
},
notes=("这是 smart-query 的自然语言兜底入口,但数据仍然只来自 iFinD。",),
),
}
def list_endpoint_specs() -> list[EndpointSpec]:
return [
_ENDPOINT_SPECS[name]
for name in sorted(_ENDPOINT_SPECS.keys())
]
def get_endpoint_spec(name: str) -> EndpointSpec:
normalized_name = name.strip().lower()
try:
return _ENDPOINT_SPECS[normalized_name]
except KeyError as exc:
supported = ", ".join(sorted(_ENDPOINT_SPECS.keys()))
raise ValueError(
f"unknown endpoint name: {name}. supported names: {supported}"
) from exc
FILE:scripts/runtime/tonghuashun_ifind_skill/llm_routing.py
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import replace
from datetime import date
import json
import os
from typing import Any
from tonghuashun_ifind_skill.routing import ResolvedEntity
from tonghuashun_ifind_skill.routing import RoutePlan
from tonghuashun_ifind_skill.routing import build_capital_flow_plan
from tonghuashun_ifind_skill.routing import build_entity_profile_plan
from tonghuashun_ifind_skill.routing import build_fundamental_plan
from tonghuashun_ifind_skill.routing import build_generic_smart_query_plan
from tonghuashun_ifind_skill.routing import build_history_plan
from tonghuashun_ifind_skill.routing import build_leaderboard_plan
from tonghuashun_ifind_skill.routing import build_limit_up_plan
from tonghuashun_ifind_skill.routing import build_market_snapshot_plan
from tonghuashun_ifind_skill.routing import build_realtime_plan
from tonghuashun_ifind_skill.routing import build_trading_calendar_plan
from tonghuashun_ifind_skill.routing import normalize_symbol
_SUPPORTED_INTENTS = {
"quote_realtime",
"quote_history",
"market_snapshot",
"fundamental_basic",
"limit_up_screen",
"leaderboard_screen",
"entity_profile",
"capital_flow",
"trading_calendar",
"generic_smart_query",
"manual_lookup",
}
_ENTITY_INTENTS = {
"quote_realtime",
"quote_history",
"fundamental_basic",
"entity_profile",
}
_TRUTHY = {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class LLMRoutingConfig:
api_key: str
model: str
base_url: str = "https://api.openai.com/v1"
timeout: float = 12.0
min_confidence: float = 0.65
@classmethod
def from_env(cls) -> "LLMRoutingConfig | None":
enabled_raw = os.environ.get("IFIND_ROUTE_LLM_ENABLED", "").strip().lower()
api_key = (
os.environ.get("IFIND_ROUTE_LLM_API_KEY")
or os.environ.get("OPENAI_API_KEY")
or ""
).strip()
if enabled_raw not in _TRUTHY:
return None
if not api_key:
return None
model = os.environ.get("IFIND_ROUTE_LLM_MODEL", "gpt-4o-mini").strip()
base_url = os.environ.get("IFIND_ROUTE_LLM_BASE_URL", cls.base_url).strip()
timeout = _float_from_env("IFIND_ROUTE_LLM_TIMEOUT", cls.timeout)
min_confidence = _float_from_env(
"IFIND_ROUTE_LLM_MIN_CONFIDENCE",
cls.min_confidence,
)
return cls(
api_key=api_key,
model=model,
base_url=base_url.rstrip("/"),
timeout=timeout,
min_confidence=min_confidence,
)
def build_llm_route_plan(
query: str,
*,
entity_lookup: Callable[[str], ResolvedEntity | None],
today: date | None = None,
session: Any | None = None,
config: LLMRoutingConfig | None = None,
) -> RoutePlan | None:
effective_config = config or LLMRoutingConfig.from_env()
if effective_config is None:
return None
response_payload = _call_router_model(
query=query,
today=today or date.today(),
session=session,
config=effective_config,
)
if response_payload is None:
return None
return _route_json_to_plan(
response_payload,
query=query,
today=today or date.today(),
entity_lookup=entity_lookup,
min_confidence=effective_config.min_confidence,
)
def _call_router_model(
*,
query: str,
today: date,
session: Any | None,
config: LLMRoutingConfig,
) -> dict[str, object] | None:
client = session if session is not None else _default_session()
payload = {
"model": config.model,
"temperature": 0,
"response_format": {"type": "json_object"},
"messages": [
{
"role": "system",
"content": (
"你是 tonghuashun-ifind-skill skill 的自然语言路由器。"
"只返回 JSON 对象,不要返回解释。所有数据查询都必须走同花顺 iFinD,"
"不得规划腾讯、东方财富或其它公开源。"
),
},
{
"role": "user",
"content": _router_prompt(query=query, today=today),
},
],
}
try:
response = client.post(
f"{config.base_url}/chat/completions",
json=payload,
headers={"Authorization": f"Bearer {config.api_key}"},
timeout=config.timeout,
)
response.raise_for_status()
raw = response.json()
except Exception:
return None
content = _extract_chat_content(raw)
if content is None:
return None
try:
parsed = json.loads(content)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
def _router_prompt(*, query: str, today: date) -> str:
return json.dumps(
{
"today": today.isoformat(),
"query": query,
"allowed_intents": sorted(_SUPPORTED_INTENTS),
"output_schema": {
"intent": "one allowed intent",
"confidence": "0.0-1.0",
"entity_text": "股票/指数名称,可为空",
"symbol": "标准代码如 600519.SH,可为空",
"entity_type": "stock 或 index,可为空",
"start_date": "YYYY-MM-DD,仅 quote_history 需要",
"end_date": "YYYY-MM-DD,仅 quote_history 需要",
"searchstring": "传给 smart_stock_picking 的原始自然语言,可为空",
"note": "manual_lookup 或低置信度原因,可为空",
},
"rules": [
"行情、历史、大盘、基本面、涨停、榜单、画像、资金流都优先映射到 iFinD endpoint。",
"公告、研报、龙虎榜、两融、北向、股东、持仓、分红、解禁、停复牌、概念板块、新股和交易日等 A 股常见问法,优先输出 generic_smart_query,并把用户原话放入 searchstring。",
"休市、开不开盘、下一个交易日等交易日历问法,优先输出 trading_calendar。",
"quote_history 没有日期时按最近 30 天。",
"不要输出公开数据源、fallback_type、provider 或非 iFinD 字段。",
"无法稳定判断时输出 manual_lookup。",
],
},
ensure_ascii=False,
)
def _route_json_to_plan(
payload: dict[str, object],
*,
query: str,
today: date,
entity_lookup: Callable[[str], ResolvedEntity | None],
min_confidence: float,
) -> RoutePlan | None:
intent = _text(payload.get("intent"))
if intent not in _SUPPORTED_INTENTS:
return None
confidence = _float(payload.get("confidence"))
if confidence is not None and confidence < min_confidence:
return None
if intent == "manual_lookup":
return RoutePlan(
intent="manual_lookup",
endpoint=None,
payload=None,
entity=None,
note=_text(payload.get("note"))
or "大模型路由无法稳定映射该请求,请先查看 references/routing.md。",
)
searchstring = _text(payload.get("searchstring")) or query
entity = None
if intent in _ENTITY_INTENTS:
entity = _entity_from_payload(payload)
if entity is None:
entity_hint = _text(payload.get("entity_text")) or query
entity = entity_lookup(entity_hint)
if entity is None:
return None
if intent == "quote_realtime":
return _with_llm_note(build_realtime_plan(entity))
if intent == "quote_history":
return _with_llm_note(
build_history_plan(
entity,
query=query,
today=today,
start_date=_text(payload.get("start_date")),
end_date=_text(payload.get("end_date")),
)
)
if intent == "fundamental_basic":
return _with_llm_note(build_fundamental_plan(entity))
if intent == "entity_profile":
return _with_llm_note(build_entity_profile_plan(entity, searchstring))
if intent == "market_snapshot":
market_query = _text(payload.get("symbol")) or _text(payload.get("entity_text")) or query
return _with_llm_note(build_market_snapshot_plan(market_query))
if intent == "limit_up_screen":
return _with_llm_note(build_limit_up_plan(searchstring))
if intent == "leaderboard_screen":
return _with_llm_note(build_leaderboard_plan(searchstring))
if intent == "capital_flow":
return _with_llm_note(build_capital_flow_plan(searchstring))
if intent == "trading_calendar":
return _with_llm_note(build_trading_calendar_plan(searchstring, today=today))
if intent == "generic_smart_query":
return _with_llm_note(build_generic_smart_query_plan(searchstring, entity=entity))
return None
def _entity_from_payload(payload: dict[str, object]) -> ResolvedEntity | None:
symbol = _text(payload.get("symbol"))
if not symbol:
return None
normalized_symbol = normalize_symbol(symbol)
entity_type = _text(payload.get("entity_type"))
if entity_type not in {"stock", "index"}:
entity_type = "index" if normalized_symbol in {
"000001.SH",
"399001.SZ",
"399006.SZ",
"000300.SH",
} else "stock"
return ResolvedEntity(
raw=_text(payload.get("entity_text")) or normalized_symbol,
symbol=normalized_symbol,
name=_text(payload.get("entity_text")),
entity_type=entity_type,
)
def _with_llm_note(plan: RoutePlan) -> RoutePlan:
note = "route_source=llm"
if plan.note:
note = f"{note}; {plan.note}"
return replace(plan, note=note)
def _extract_chat_content(payload: object) -> str | None:
if not isinstance(payload, dict):
return None
choices = payload.get("choices")
if not isinstance(choices, list) or not choices:
return None
first = choices[0]
if not isinstance(first, dict):
return None
message = first.get("message")
if not isinstance(message, dict):
return None
content = message.get("content")
return content if isinstance(content, str) and content.strip() else None
def _default_session() -> Any:
try:
import requests
except ModuleNotFoundError as exc: # pragma: no cover
raise RuntimeError("requests is required for LLM routing") from exc
return requests.Session()
def _text(value: object) -> str | None:
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _float(value: object) -> float | None:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return None
return None
def _float_from_env(name: str, default: float) -> float:
value = os.environ.get(name)
parsed = _float(value)
return default if parsed is None else parsed
FILE:scripts/runtime/tonghuashun_ifind_skill/models.py
from __future__ import annotations
from dataclasses import asdict
from dataclasses import dataclass
from datetime import datetime
from datetime import timezone
from typing import Any
@dataclass
class TokenBundle:
access_token: str
refresh_token: str
expires_at: str | None = None
def to_dict(self) -> dict[str, str | None]:
return asdict(self)
@classmethod
def from_dict(cls, data: Any) -> "TokenBundle":
if not isinstance(data, dict):
raise ValueError("token state must be a JSON object")
access_token = cls._require_text(data, "access_token")
refresh_token = cls._require_text(data, "refresh_token")
expires_at = data.get("expires_at")
if expires_at is not None and not isinstance(expires_at, str):
raise ValueError("expires_at must be a string or null")
return cls(
access_token=access_token,
refresh_token=refresh_token,
expires_at=expires_at,
)
def is_stale(self, *, now: datetime | None = None) -> bool:
if self.expires_at is None:
return True
current_time = self._normalize_datetime(now or datetime.now(timezone.utc))
try:
return current_time >= self.expires_at_datetime
except ValueError:
return True
@property
def expires_at_datetime(self) -> datetime:
if self.expires_at is None:
raise ValueError("expires_at is not set")
try:
parsed = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("expires_at must be a valid ISO 8601 datetime") from exc
return self._normalize_datetime(parsed)
@staticmethod
def _normalize_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@staticmethod
def _require_text(data: dict[str, Any], key: str) -> str:
value = data.get(key)
if not isinstance(value, str):
raise ValueError(f"{key} must be a string")
return value
@dataclass
class ErrorPayload:
type: str
message: str
errorcode: int | str | None = None
errmsg: str | None = None
def to_dict(self) -> dict[str, object]:
payload: dict[str, object] = {
"type": self.type,
"message": self.message,
}
if self.errorcode is not None:
payload["errorcode"] = self.errorcode
if self.errmsg is not None:
payload["errmsg"] = self.errmsg
return payload
@dataclass
class ResponseMeta:
timestamp: str
def to_dict(self) -> dict[str, str]:
return {"timestamp": self.timestamp}
@dataclass
class ResponseEnvelope:
ok: bool
endpoint: str
token_source: str
data: object | None
error: ErrorPayload | None
meta: ResponseMeta
def to_dict(self) -> dict[str, object]:
return {
"ok": self.ok,
"endpoint": self.endpoint,
"token_source": self.token_source,
"data": self.data,
"error": None if self.error is None else self.error.to_dict(),
"meta": self.meta.to_dict(),
}
def format_timestamp(now: datetime | None = None) -> str:
current = now or datetime.now(timezone.utc)
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
else:
current = current.astimezone(timezone.utc)
current = current.replace(microsecond=0)
return current.isoformat().replace("+00:00", "Z")
FILE:scripts/runtime/tonghuashun_ifind_skill/routing.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from datetime import timedelta
import re
from typing import Callable
from typing import Literal
Intent = Literal[
"quote_realtime",
"quote_history",
"market_snapshot",
"fundamental_basic",
"limit_up_screen",
"leaderboard_screen",
"entity_profile",
"capital_flow",
"trading_calendar",
"generic_smart_query",
"manual_lookup",
]
EntityType = Literal["stock", "index"]
HISTORY_INDICATORS = "open,high,low,close,volume,amount,changeRatio"
REALTIME_INDICATORS = (
"open,high,low,latest,changeRatio,change,preClose,"
"volume,amount,turnoverRatio,volumeRatio,amplitude,pb"
)
MARKET_SNAPSHOT_INDICATORS = "open,high,low,latest,changeRatio,change,preClose,volume,amount"
FINANCIAL_QUERY_TEMPLATE = (
"{target} 营业总收入 归属于母公司所有者的净利润 扣除非经常性损益后的净利润 "
"销售毛利率 销售净利率 净资产收益率roe 资产负债率 经营活动产生的现金流量净额 存货"
)
VALUATION_QUERY_TEMPLATE = "{target} 量比 换手率 市盈率 市净率 总市值 流通市值"
FORECAST_QUERY_TEMPLATE = "{target} 预测净利润平均值 预测主营业务收入平均值 2026 2027"
DEFAULT_MARKET_ENTITIES = (
("上证指数", "000001.SH"),
("深证成指", "399001.SZ"),
("创业板指", "399006.SZ"),
("沪深300", "000300.SH"),
)
KNOWN_INDEX_ENTITIES = DEFAULT_MARKET_ENTITIES + (
("上证50", "000016.SH"),
("科创50", "000688.SH"),
("中证500", "000905.SH"),
("中证1000", "000852.SH"),
("北证50", "899050.BJ"),
)
POPULAR_STOCK_ALIASES = {
"茅台": ("贵州茅台", "600519.SH"),
"茅子": ("贵州茅台", "600519.SH"),
"宁王": ("宁德时代", "300750.SZ"),
"宁德": ("宁德时代", "300750.SZ"),
"catl": ("宁德时代", "300750.SZ"),
"迪王": ("比亚迪", "002594.SZ"),
"比亚迪": ("比亚迪", "002594.SZ"),
"byd": ("比亚迪", "002594.SZ"),
"招行": ("招商银行", "600036.SH"),
"平安银行": ("平安银行", "000001.SZ"),
"中国平安": ("中国平安", "601318.SH"),
"平安": ("中国平安", "601318.SH"),
"中芯国际": ("中芯国际", "688981.SH"),
"中芯": ("中芯国际", "688981.SH"),
"迈瑞医疗": ("迈瑞医疗", "300760.SZ"),
"迈瑞": ("迈瑞医疗", "300760.SZ"),
"药明康德": ("药明康德", "603259.SH"),
"药明": ("药明康德", "603259.SH"),
"东财": ("东方财富", "300059.SZ"),
"工行": ("工商银行", "601398.SH"),
"建行": ("建设银行", "601939.SH"),
"农行": ("农业银行", "601288.SH"),
"中行": ("中国银行", "601988.SH"),
"五粮液": ("五粮液", "000858.SZ"),
"隆基": ("隆基绿能", "601012.SH"),
"隆基绿能": ("隆基绿能", "601012.SH"),
"格力": ("格力电器", "000651.SZ"),
"格力电器": ("格力电器", "000651.SZ"),
"美的": ("美的集团", "000333.SZ"),
"美的集团": ("美的集团", "000333.SZ"),
"万科": ("万科A", "000002.SZ"),
"万科a": ("万科A", "000002.SZ"),
"长电": ("长江电力", "600900.SH"),
"长江电力": ("长江电力", "600900.SH"),
"紫金": ("紫金矿业", "601899.SH"),
"紫金矿业": ("紫金矿业", "601899.SH"),
"中信证券": ("中信证券", "600030.SH"),
"海康": ("海康威视", "002415.SZ"),
"海康威视": ("海康威视", "002415.SZ"),
"伊利": ("伊利股份", "600887.SH"),
"伊利股份": ("伊利股份", "600887.SH"),
"牧原": ("牧原股份", "002714.SZ"),
"牧原股份": ("牧原股份", "002714.SZ"),
"立讯": ("立讯精密", "002475.SZ"),
"立讯精密": ("立讯精密", "002475.SZ"),
"恒瑞": ("恒瑞医药", "600276.SH"),
"恒瑞医药": ("恒瑞医药", "600276.SH"),
"万华": ("万华化学", "600309.SH"),
"万华化学": ("万华化学", "600309.SH"),
"三一": ("三一重工", "600031.SH"),
"三一重工": ("三一重工", "600031.SH"),
"海尔": ("海尔智家", "600690.SH"),
"海尔智家": ("海尔智家", "600690.SH"),
"中兴": ("中兴通讯", "000063.SZ"),
"中兴通讯": ("中兴通讯", "000063.SZ"),
"京东方": ("京东方A", "000725.SZ"),
"京东方a": ("京东方A", "000725.SZ"),
"tcl科技": ("TCL科技", "000100.SZ"),
"寒武纪": ("寒武纪", "688256.SH"),
"中际旭创": ("中际旭创", "300308.SZ"),
"新易盛": ("新易盛", "300502.SZ"),
}
INDEX_ALIASES = {
"上证指数": ("上证指数", "000001.SH"),
"上证综指": ("上证指数", "000001.SH"),
"上证": ("上证指数", "000001.SH"),
"沪指": ("上证指数", "000001.SH"),
"深证成指": ("深证成指", "399001.SZ"),
"深成指": ("深证成指", "399001.SZ"),
"深指": ("深证成指", "399001.SZ"),
"创业板指": ("创业板指", "399006.SZ"),
"创业板指数": ("创业板指", "399006.SZ"),
"创业板": ("创业板指", "399006.SZ"),
"沪深300": ("沪深300", "000300.SH"),
"hs300": ("沪深300", "000300.SH"),
"上证50": ("上证50", "000016.SH"),
"sz50": ("上证50", "000016.SH"),
"科创50": ("科创50", "000688.SH"),
"科创板50": ("科创50", "000688.SH"),
"中证500": ("中证500", "000905.SH"),
"zz500": ("中证500", "000905.SH"),
"中证1000": ("中证1000", "000852.SH"),
"zz1000": ("中证1000", "000852.SH"),
"北证50": ("北证50", "899050.BJ"),
"000001.sh": ("上证指数", "000001.SH"),
"399001.sz": ("深证成指", "399001.SZ"),
"399006.sz": ("创业板指", "399006.SZ"),
"000300.sh": ("沪深300", "000300.SH"),
"000016.sh": ("上证50", "000016.SH"),
"000688.sh": ("科创50", "000688.SH"),
"000905.sh": ("中证500", "000905.SH"),
"000852.sh": ("中证1000", "000852.SH"),
"899050.bj": ("北证50", "899050.BJ"),
}
_ETF_SH_PREFIXES = ("50", "51", "52", "56", "58")
_ETF_SZ_PREFIXES = ("15", "16", "18")
_SYMBOL_RE = re.compile(
r"(?i)(?<![A-Z0-9.])(?:SH|SZ|BJ)?\d{6}(?:\.(?:SH|SZ|BJ)|(?:SH|SZ|BJ))?(?![A-Z0-9.])"
)
_DATE_RANGE_RE = re.compile(r"(?P<start>\d{4}-\d{2}-\d{2}).*?(?P<end>\d{4}-\d{2}-\d{2})")
_SINGLE_DATE_RE = re.compile(r"(?P<date>\d{4}-\d{2}-\d{2})")
_MONTH_DAY_RE = re.compile(r"(?P<month>\d{1,2})月(?P<day>\d{1,2})[日号]?")
_MANUAL_LOOKUP_PATTERNS = ("pdf", "下载链接", "全文下载", "原文")
_FUNDAMENTAL_PATTERNS = (
"基本面",
"财务",
"估值",
"营收",
"净利润",
"roe",
"市盈率",
"市净率",
"pe",
"pb",
"市值",
)
_PRECISE_FINANCIAL_METRIC_PATTERNS = (
"营收",
"营业收入",
"营业总收入",
"收入",
"毛利率",
"净利率",
"净利润",
"扣非",
"roe",
"资产负债率",
"现金流",
"市盈率",
"市净率",
"pe",
"pb",
"市值",
"总市值",
"流通市值",
"同比",
"环比",
)
_HISTORY_PATTERNS = (
"历史",
"走势",
"k线",
"日k",
"周k",
"月k",
"近一个月",
"最近一个月",
"近1个月",
"近一周",
"最近一周",
"近1周",
"近三个月",
"最近三个月",
"近3个月",
"近半年",
"近一年",
)
_MARKET_PATTERNS = (
"大盘",
"盘面",
"市场表现",
"市场快照",
"指数",
"a股",
"沪指",
"深指",
"创业板",
)
_LIMIT_UP_PATTERNS = ("涨停", "涨停板", "封板")
_LEADERBOARD_PATTERNS = ("榜", "排行", "排名", "top", "前十", "前二十", "前30", "前50")
_PROFILE_PATTERNS = (
"主营业务",
"公司简介",
"公司介绍",
"干啥",
"干什么",
"做什么",
"是做什么的",
"业务是什么",
"属于什么行业",
)
_TRADING_CALENDAR_PATTERNS = (
"交易日",
"休市",
"开不开盘",
"是否开盘",
"是否交易",
)
_CAPITAL_FLOW_PATTERNS = (
"资金流",
"主力资金",
"资金净流入",
"资金净流出",
"净流入",
"净流出",
)
_COMMON_A_SHARE_SMART_PATTERNS = (
"公告",
"业绩预告",
"业绩快报",
"一季度报告",
"三季度报告",
"年度报告",
"半年报",
"一季报",
"三季报",
"年报",
"季报",
"报告",
"摘要",
"研报",
"评级",
"目标价",
"消息",
"新闻",
"资讯",
"舆情",
"龙虎榜",
"大宗交易",
"异动",
"融资融券",
"融资余额",
"融券",
"两融",
"资金",
"资金往",
"主力",
"外资",
"北向",
"沪股通",
"深股通",
"陆股通",
"持股",
"持仓",
"股东",
"十大股东",
"流通股东",
"机构持仓",
"基金持仓",
"分红",
"派息",
"送转",
"除权",
"除息",
"股息率",
"解禁",
"限售",
"停牌",
"复牌",
"跌停",
"风险警示",
"退市",
"所属概念",
"概念",
"题材",
"产业链",
"板块",
"半导体",
"新能源",
"白酒",
"低估值",
"便宜",
"利好",
"机构",
"龙头",
"新股",
"上市公司",
"申购",
"中签",
"打新",
"发行价",
"上市日期",
"交易日",
"休市",
)
_PRICE_FIELD_PATTERNS = (
"开盘价",
"收盘价",
"最高价",
"最低价",
"成交量",
"成交额",
"量比",
)
_QUERY_NOISE_PATTERNS = (
r"看看",
r"看下",
r"看一下",
r"查下",
r"查一下",
r"请问",
r"麻烦",
r"帮我",
r"给我",
r"我想",
r"想看",
r"^看",
r"^查",
r"能不能",
r"能否",
r"有没有",
r"有没有相关",
r"相关",
r"现在",
r"目前",
r"今天",
r"今日",
r"昨天",
r"昨日",
r"明天",
r"最近",
r"近期",
r"今年",
r"去年",
r"下一个",
r"上一个",
r"时候",
r"日期",
r"最新价",
r"现价",
r"股价",
r"股票",
r"个股",
r"行情",
r"报价",
r"表现",
r"怎么样",
r"咋样",
r"如何",
r"还行不",
r"还能看",
r"能看",
r"情况",
r"记录",
r"数据",
r"变化",
r"安排",
r"多少",
r"多少[??]?",
r"多少钱",
r"钱了",
r"吗",
r"呢",
r"吧",
r"有吗",
r"有啥",
r"啥消息",
r"啥研报",
r"涨没涨",
r"涨了没",
r"涨了吗",
r"红没红",
r"红了没",
r"红了吗",
r"红了",
r"绿没绿",
r"绿了吗",
r"绿了",
r"跌没跌",
r"跌了没",
r"跌了吗",
r"跌得多",
r"跌多",
r"一下",
r"一下子",
r"走势",
r"历史",
r"k线",
r"日k",
r"周k",
r"月k",
r"基本面",
r"财务",
r"估值",
r"开盘价",
r"收盘价",
r"最高价",
r"最低价",
r"成交量",
r"成交额",
r"量比",
r"换手率",
r"振幅",
r"涨跌幅",
r"涨幅",
r"跌幅",
r"价格",
r"市价",
r"营业总收入",
r"营业收入",
r"营收",
r"收入",
r"归母净利润",
r"扣非净利润",
r"净利润",
r"毛利率",
r"净利率",
r"资产负债率",
r"现金流",
r"总市值",
r"流通市值",
r"市盈率",
r"市净率",
r"动态pe",
r"静态pe",
r"滚动pe",
r"pe",
r"pb",
r"roe",
r"大盘",
r"指数",
r"近一个月",
r"最近一个月",
r"近1个月",
r"近一周",
r"最近一周",
r"近1周",
r"近三个月",
r"最近三个月",
r"近3个月",
r"近半年",
r"近一年",
r"近\d+天",
r"主营业务是什么",
r"主营业务",
r"业务是什么",
r"公司简介",
r"公司介绍",
r"干啥的",
r"干啥",
r"干什么",
r"做什么的",
r"是做什么的",
r"属于什么行业",
r"什么行业",
r"是什么",
r"公告",
r"业绩预告",
r"业绩快报",
r"一季度报告",
r"三季度报告",
r"年度报告",
r"半年报",
r"一季报",
r"三季报",
r"年报",
r"季报",
r"报告",
r"摘要",
r"研报",
r"评级",
r"目标价",
r"新闻",
r"资讯",
r"舆情",
r"龙虎榜",
r"大宗交易",
r"异动",
r"融资融券",
r"融资余额",
r"融券",
r"两融",
r"北向资金",
r"北向",
r"沪股通",
r"深股通",
r"陆股通",
r"十大流通股东",
r"十大股东",
r"流通股东",
r"机构持仓",
r"基金持仓",
r"持股",
r"持仓",
r"股东",
r"分红",
r"派息",
r"送转",
r"除权",
r"除息",
r"股息率",
r"时间表",
r"比例",
r"解禁",
r"限售",
r"停牌",
r"复牌",
r"风险警示",
r"退市",
r"所属概念",
r"概念",
r"题材",
r"产业链",
r"板块",
r"新股",
r"申购",
r"中签",
r"打新",
r"发行价",
r"上市日期",
r"交易日",
r"休市",
r"开不开盘",
)
@dataclass(frozen=True)
class ResolvedEntity:
raw: str
symbol: str
name: str | None
entity_type: EntityType
@dataclass(frozen=True)
class RoutePlan:
intent: Intent
endpoint: str | None
payload: dict[str, object] | None
entity: ResolvedEntity | None
note: str | None = None
def build_route_plan(
query: str,
*,
entity_lookup: Callable[[str], ResolvedEntity | None],
today: date | None = None,
) -> RoutePlan:
normalized_query = (query or "").strip()
effective_today = today or date.today()
if _is_blank_or_punctuation_only(normalized_query):
return _manual_lookup_plan(
"请输入明确的股票、指数、指标、日期或筛选条件;空白或纯标点无法路由到 iFinD。",
)
if _needs_manual_lookup(normalized_query):
return _manual_lookup_plan(
"这个请求不在内置常见路由里。请先阅读 references/routing.md,再决定是否用 api-call;如果文档里也没有合适接口,就明确告诉用户当前 skill 未覆盖该 iFinD 能力。",
)
intent = _detect_intent(normalized_query)
if intent == "limit_up_screen":
return build_limit_up_plan(normalized_query)
if intent == "leaderboard_screen":
return build_leaderboard_plan(normalized_query)
if intent == "capital_flow":
return build_capital_flow_plan(normalized_query)
if intent == "trading_calendar":
return build_trading_calendar_plan(normalized_query, today=effective_today)
if intent == "market_snapshot":
return build_market_snapshot_plan(normalized_query)
entity = _resolve_entity(normalized_query, entity_lookup)
if entity is None:
if intent == "quote_realtime" and not _has_generic_query_signal(normalized_query):
return _manual_lookup_plan(
"请输入明确的股票、指数、指标、日期或筛选条件;当前问题过于笼统,无法稳定路由到 iFinD。",
)
return build_generic_smart_query_plan(normalized_query)
if intent == "generic_smart_query":
return build_generic_smart_query_plan(normalized_query, entity=entity)
if intent == "quote_history":
return build_history_plan(entity, query=normalized_query, today=effective_today)
if intent == "fundamental_basic":
if _is_precise_financial_query(normalized_query):
return build_generic_smart_query_plan(normalized_query, entity=entity)
return build_fundamental_plan(entity)
if intent == "entity_profile":
return build_entity_profile_plan(entity, normalized_query)
if entity.entity_type == "index":
return build_market_snapshot_plan(normalized_query, entity=entity)
return build_realtime_plan(entity)
def build_realtime_plan(entity: ResolvedEntity) -> RoutePlan:
indicators = (
MARKET_SNAPSHOT_INDICATORS
if entity.entity_type == "index"
else REALTIME_INDICATORS
)
return RoutePlan(
intent="quote_realtime",
endpoint="/real_time_quotation",
payload={"codes": entity.symbol, "indicators": indicators},
entity=entity,
)
def build_history_plan(
entity: ResolvedEntity,
*,
query: str,
today: date,
start_date: str | None = None,
end_date: str | None = None,
) -> RoutePlan:
start_value, end_value = _parse_date_range(query, today=today)
if start_date:
start_value = start_date
if end_date:
end_value = end_date
return RoutePlan(
intent="quote_history",
endpoint="/cmd_history_quotation",
payload={
"codes": entity.symbol,
"indicators": HISTORY_INDICATORS,
"startdate": start_value,
"enddate": end_value,
},
entity=entity,
)
def build_market_snapshot_plan(
query: str | None = None,
*,
entity: ResolvedEntity | None = None,
) -> RoutePlan:
entity = entity or resolve_common_index_entity(query or "")
if entity is not None:
codes = entity.symbol
else:
codes = ",".join(symbol for _, symbol in DEFAULT_MARKET_ENTITIES)
return RoutePlan(
intent="market_snapshot",
endpoint="/real_time_quotation",
payload={"codes": codes, "indicators": MARKET_SNAPSHOT_INDICATORS},
entity=entity,
)
def build_fundamental_plan(entity: ResolvedEntity) -> RoutePlan:
target = entity.symbol
return RoutePlan(
intent="fundamental_basic",
endpoint="/smart_stock_picking",
payload={
"searchstrings": [
FINANCIAL_QUERY_TEMPLATE.format(target=target),
VALUATION_QUERY_TEMPLATE.format(target=target),
FORECAST_QUERY_TEMPLATE.format(target=target),
],
"searchtype": "stock",
},
entity=entity,
)
def build_limit_up_plan(query: str) -> RoutePlan:
return RoutePlan(
intent="limit_up_screen",
endpoint="/smart_stock_picking",
payload={"searchstring": query, "searchtype": "stock"},
entity=None,
)
def build_leaderboard_plan(query: str) -> RoutePlan:
return RoutePlan(
intent="leaderboard_screen",
endpoint="/smart_stock_picking",
payload={
"searchstring": query,
"searchtype": "stock",
},
entity=None,
)
def build_entity_profile_plan(entity: ResolvedEntity, query: str) -> RoutePlan:
return RoutePlan(
intent="entity_profile",
endpoint="/smart_stock_picking",
payload={
"searchstring": _normalize_entity_profile_query(query, entity),
"searchtype": "stock",
},
entity=entity,
)
def build_capital_flow_plan(query: str) -> RoutePlan:
return RoutePlan(
intent="capital_flow",
endpoint="/smart_stock_picking",
payload={"searchstring": query, "searchtype": "stock"},
entity=None,
)
def build_generic_smart_query_plan(
query: str,
*,
entity: ResolvedEntity | None = None,
) -> RoutePlan:
searchstring = _normalize_generic_smart_query(query, entity)
note = "route_source=ifind_smart_stock_picking"
if searchstring != query:
note = f"{note}; normalized_casual_query"
return RoutePlan(
intent="generic_smart_query",
endpoint="/smart_stock_picking",
payload={"searchstring": searchstring, "searchtype": "stock"},
entity=entity,
note=note,
)
def build_trading_calendar_plan(query: str, *, today: date) -> RoutePlan:
start = _parse_calendar_start_date(query, today=today)
end = start + timedelta(days=14)
return RoutePlan(
intent="trading_calendar",
endpoint="/date_sequence",
payload={
"codes": "000001.SH",
"startdate": start.isoformat(),
"enddate": end.isoformat(),
"functionpara": {"Days": "Tradedays", "Fill": "Omit"},
"indipara": [
{
"indicator": "ths_close_price_stock",
"indiparams": ["", "100", ""],
}
],
},
entity=ResolvedEntity(
raw="A股交易日历",
symbol="000001.SH",
name="上证指数",
entity_type="index",
),
note="route_source=ifind_date_sequence; response time 字段即 iFinD 返回的交易日序列",
)
def resolve_common_index_entity(text: str) -> ResolvedEntity | None:
normalized = (text or "").strip().lower()
for alias, (name, symbol) in sorted(
INDEX_ALIASES.items(),
key=lambda item: len(item[0]),
reverse=True,
):
if alias in normalized:
return ResolvedEntity(raw=text, symbol=symbol, name=name, entity_type="index")
return None
def extract_entity_from_search_payload(raw: str, payload: dict[str, object]) -> ResolvedEntity | None:
if not isinstance(payload, dict):
return None
tables = payload.get("tables")
if not isinstance(tables, list) or not tables:
return None
first = tables[0]
if not isinstance(first, dict):
return None
table = first.get("table")
if not isinstance(table, dict):
return None
symbol = _first_text(table, ("股票代码", "证券代码", "thscode"))
if not symbol:
return None
normalized_symbol = normalize_symbol(symbol)
name = _first_text(table, ("股票简称", "证券简称", "股票名称", "证券名称"))
entity_type: EntityType = "index" if _is_known_index_symbol(normalized_symbol) else "stock"
return ResolvedEntity(raw=raw, symbol=normalized_symbol, name=name, entity_type=entity_type)
def normalize_symbol(text: str) -> str:
raw = (text or "").strip().upper()
if not raw:
return raw
if raw in {"000001.SH", "399001.SZ", "399006.SZ", "000300.SH"}:
return raw
if raw.startswith(("SH", "SZ", "BJ")) and raw[2:].isdigit():
return f"{raw[2:]}.{raw[:2]}"
if len(raw) == 8 and raw[:6].isdigit() and raw[6:] in {"SH", "SZ", "BJ"}:
return f"{raw[:6]}.{raw[6:]}"
if "." in raw:
code, market = raw.split(".", 1)
market = market.upper()
return f"{code}.{market}"
if not raw.isdigit() or len(raw) != 6:
return raw
if raw.startswith(_ETF_SH_PREFIXES):
return f"{raw}.SH"
if raw.startswith(_ETF_SZ_PREFIXES):
return f"{raw}.SZ"
if _is_bse_code(raw):
return f"{raw}.BJ"
if raw.startswith(("600", "601", "603", "605", "688")):
return f"{raw}.SH"
return f"{raw}.SZ"
def _detect_intent(query: str) -> Intent:
lowered = query.lower()
if any(pattern in lowered for pattern in _LIMIT_UP_PATTERNS):
return "limit_up_screen"
if any(pattern in lowered for pattern in _CAPITAL_FLOW_PATTERNS):
return "capital_flow"
if _is_trading_calendar_query(lowered):
return "trading_calendar"
if _is_leaderboard_query(lowered):
return "leaderboard_screen"
if any(pattern in lowered for pattern in _PROFILE_PATTERNS):
return "entity_profile"
if any(
pattern in lowered
for pattern in _FUNDAMENTAL_PATTERNS + _PRECISE_FINANCIAL_METRIC_PATTERNS
):
return "fundamental_basic"
if any(pattern in lowered for pattern in _COMMON_A_SHARE_SMART_PATTERNS):
return "generic_smart_query"
if _looks_like_dated_price_lookup(query, lowered):
return "quote_history"
if _DATE_RANGE_RE.search(query) or any(pattern in lowered for pattern in _HISTORY_PATTERNS):
return "quote_history"
if any(pattern in lowered for pattern in _MARKET_PATTERNS):
return "market_snapshot"
return "quote_realtime"
def _is_leaderboard_query(lowered_query: str) -> bool:
has_metric = any(
pattern in lowered_query
for pattern in (
"成交额",
"成交金额",
"涨幅",
"跌幅",
"换手率",
"振幅",
"量比",
"领涨",
"领跌",
)
)
if not has_metric:
return False
return any(pattern in lowered_query for pattern in _LEADERBOARD_PATTERNS) or any(
pattern in lowered_query
for pattern in (
"最大",
"最高",
"最多",
"靠前",
"高的",
"低的",
"最好",
"最差",
)
)
def _is_trading_calendar_query(lowered_query: str) -> bool:
if not any(pattern in lowered_query for pattern in _TRADING_CALENDAR_PATTERNS):
return False
stock_lifecycle_terms = ("上市日期", "申购", "中签", "打新", "停牌", "复牌")
return not any(term in lowered_query for term in stock_lifecycle_terms)
def _needs_manual_lookup(query: str) -> bool:
lowered = query.lower()
return any(pattern in lowered for pattern in _MANUAL_LOOKUP_PATTERNS)
def _is_blank_or_punctuation_only(query: str) -> bool:
return not re.search(r"[A-Za-z0-9\u4e00-\u9fff]", query or "")
def _resolve_entity(
query: str,
entity_lookup: Callable[[str], ResolvedEntity | None],
) -> ResolvedEntity | None:
index_entity = resolve_common_index_entity(query)
if index_entity is not None:
return index_entity
symbol_candidate = _extract_symbol_candidate(query)
if symbol_candidate:
normalized_symbol = normalize_symbol(symbol_candidate)
entity_type: EntityType = "index" if _is_known_index_symbol(normalized_symbol) else "stock"
known_name = _known_index_name(normalized_symbol)
return ResolvedEntity(
raw=symbol_candidate,
symbol=normalized_symbol,
name=known_name,
entity_type=entity_type,
)
entity_hint = _extract_entity_hint(query)
if not entity_hint:
return None
alias_entity = resolve_popular_stock_alias(query, entity_hint=entity_hint)
if alias_entity is not None:
lookup_entity = None
if _is_entity_hint_likely_security_name(entity_hint):
lookup_entity = entity_lookup(entity_hint)
if lookup_entity is None or _is_alias_lookup_mismatch(alias_entity, lookup_entity):
return alias_entity
return lookup_entity
if not _is_entity_hint_likely_security_name(entity_hint):
return None
return entity_lookup(entity_hint)
def resolve_popular_stock_alias(
text: str,
*,
entity_hint: str | None = None,
) -> ResolvedEntity | None:
normalized_text = (text or "").strip().lower()
normalized_hint = (entity_hint or "").strip().lower()
if not normalized_text and not normalized_hint:
return None
for alias, (name, symbol) in sorted(
POPULAR_STOCK_ALIASES.items(),
key=lambda item: len(item[0]),
reverse=True,
):
normalized_alias = alias.lower()
if (
normalized_alias not in normalized_text
and normalized_alias not in normalized_hint
):
continue
if _alias_match_is_obviously_not_a_stock(normalized_text, normalized_alias):
continue
return ResolvedEntity(
raw=alias,
symbol=symbol,
name=name,
entity_type="stock",
)
return None
def _extract_symbol_candidate(query: str) -> str | None:
match = _SYMBOL_RE.search(query or "")
if not match:
return None
return match.group(0)
def _extract_entity_hint(query: str) -> str:
stripped = query or ""
for pattern in _QUERY_NOISE_PATTERNS:
stripped = re.sub(pattern, " ", stripped, flags=re.IGNORECASE)
stripped = re.sub(r"\d{4}-\d{2}-\d{2}", " ", stripped)
stripped = re.sub(r"\d{4}年(?:\d{1,2}月)?(?:\d{1,2}[日号]?)?", " ", stripped)
stripped = re.sub(r"(?<!\d)\d{4}(?!\d)", " ", stripped)
stripped = re.sub(r"(?:近|最近)?[一二三四五六七八九十\d]+年", " ", stripped)
stripped = re.sub(r"(?:近|最近)?[一二三四五六七八九十\d]+(?:个)?季度", " ", stripped)
stripped = re.sub(r"[,,。??!!、::;;()()【】《》〈〉「」『』\[\]{}\"'“”‘’]", " ", stripped)
stripped = re.sub(r"\s+", "", stripped)
return stripped.strip("的和与及是为到至")
def _is_entity_hint_likely_security_name(entity_hint: str) -> bool:
if not entity_hint:
return False
lowered = entity_hint.lower()
broad_or_condition_terms = (
"a股",
"股票",
"哪些",
"哪个",
"什么",
"下一个",
"下个",
"上一个",
"上个",
"时候",
"哪天",
"日期",
"多少",
"啥",
"买啥",
"买了啥",
"卖啥",
"卖了啥",
"哪儿",
"哪里",
"往哪",
"往哪里",
"开不开盘",
"半导体",
"新能源",
"白酒",
"cpo",
"算力",
"机器人",
"银行股",
"板块",
"行业",
"产业链",
"概念",
"龙头",
"低于",
"高于",
"大于",
"小于",
"不低于",
"不高于",
"近5日",
"近10日",
"放量",
"缩量",
"申购",
"中签",
"交易日",
"休市",
"停牌",
"复牌",
"跌停",
"外资",
"主力",
"top",
)
return not any(term in lowered for term in broad_or_condition_terms)
def _is_alias_lookup_mismatch(
alias_entity: ResolvedEntity,
lookup_entity: ResolvedEntity,
) -> bool:
if lookup_entity.entity_type != "stock":
return True
return normalize_symbol(lookup_entity.symbol) != alias_entity.symbol
def _alias_match_is_obviously_not_a_stock(
normalized_text: str,
normalized_alias: str,
) -> bool:
if f"{normalized_alias}市" in normalized_text:
return True
if normalized_alias == "药明" and any(
phrase in normalized_text for phrase in ("药明生物", "港股", "美股", "h股")
):
return True
return f"{normalized_alias}有哪些" in normalized_text and any(
term in normalized_text for term in ("上市公司", "地区", "城市", "地方")
)
def _parse_date_range(query: str, *, today: date) -> tuple[str, str]:
match = _DATE_RANGE_RE.search(query or "")
if match:
return match.group("start"), match.group("end")
single_date = _extract_single_date(query, today=today)
if single_date is not None:
return single_date, single_date
days = _relative_days(query)
start = today - timedelta(days=days)
return start.isoformat(), today.isoformat()
def _extract_single_date(query: str, *, today: date) -> str | None:
single_date_match = _SINGLE_DATE_RE.search(query or "")
if single_date_match:
return single_date_match.group("date")
month_day_match = _MONTH_DAY_RE.search(query or "")
if not month_day_match:
return None
month = int(month_day_match.group("month"))
day = int(month_day_match.group("day"))
try:
return date(today.year, month, day).isoformat()
except ValueError:
return None
def _parse_calendar_start_date(query: str, *, today: date) -> date:
single_date = _extract_single_date(query, today=today)
if single_date is not None:
return date.fromisoformat(single_date)
lowered = query.lower()
if "明天" in lowered or "下一个" in lowered or "下个" in lowered:
return today + timedelta(days=1)
return today
def _relative_days(query: str) -> int:
lowered = query.lower()
explicit_days = re.search(r"近(\d+)天", lowered)
if explicit_days:
return max(int(explicit_days.group(1)), 1)
if any(pattern in lowered for pattern in ("近一周", "最近一周", "近1周")):
return 7
if any(pattern in lowered for pattern in ("近一个月", "最近一个月", "近1个月")):
return 30
if any(pattern in lowered for pattern in ("近三个月", "最近三个月", "近3个月")):
return 90
if "近半年" in lowered:
return 180
if "近一年" in lowered:
return 365
return 30
def _looks_like_dated_price_lookup(query: str, lowered_query: str) -> bool:
has_date = bool(_SINGLE_DATE_RE.search(query or "") or _MONTH_DAY_RE.search(query or ""))
if not has_date:
return False
return any(pattern in lowered_query for pattern in _PRICE_FIELD_PATTERNS)
def _is_precise_financial_query(query: str) -> bool:
lowered = query.lower()
return any(pattern in lowered for pattern in _PRECISE_FINANCIAL_METRIC_PATTERNS)
def _normalize_entity_profile_query(query: str, entity: ResolvedEntity) -> str:
if entity.name and any(term in query for term in ("干啥", "干什么", "做什么")):
return f"{entity.name}主营业务是什么"
return query
def _normalize_generic_smart_query(
query: str,
entity: ResolvedEntity | None,
) -> str:
if entity is None or not entity.name:
if "北向" in query and any(term in query for term in ("买啥", "买了啥", "流入哪些")):
return "北向资金持股增加前十"
return query
casual_terms = ("有啥", "啥", "怎么样", "咋样", "有吗", "消息")
if not any(term in query for term in casual_terms):
return query
if "分红" in query or "派息" in query:
return f"{entity.name}分红记录"
if "研报" in query or "评级" in query or "目标价" in query:
return f"{entity.name}研报"
if "公告" in query or "消息" in query or "资讯" in query or "新闻" in query:
return f"{entity.name}最近公告"
return query
def _has_generic_query_signal(query: str) -> bool:
lowered = query.lower()
return any(
pattern in lowered
for pattern in (
_COMMON_A_SHARE_SMART_PATTERNS
+ _CAPITAL_FLOW_PATTERNS
+ _MARKET_PATTERNS
+ _LEADERBOARD_PATTERNS
+ _LIMIT_UP_PATTERNS
)
)
def _manual_lookup_plan(note: str) -> RoutePlan:
return RoutePlan(
intent="manual_lookup",
endpoint=None,
payload=None,
entity=None,
note=note,
)
def _is_bse_code(code: str) -> bool:
return len(code) == 6 and code.isdigit() and code.startswith(("4", "8", "92"))
def _is_known_index_symbol(symbol: str) -> bool:
return any(symbol == known_symbol for _, known_symbol in KNOWN_INDEX_ENTITIES)
def _known_index_name(symbol: str) -> str | None:
for name, known_symbol in KNOWN_INDEX_ENTITIES:
if symbol == known_symbol:
return name
return None
def _first_text(table: dict[str, object], keys: tuple[str, ...]) -> str | None:
for key in keys:
value = table.get(key)
if isinstance(value, list):
value = value[0] if value else None
if isinstance(value, str) and value.strip():
return value.strip()
return None
FILE:scripts/runtime/tonghuashun_ifind_skill/state.py
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from tonghuashun_ifind_skill.models import TokenBundle
class TokenStateStore:
def __init__(self, path: Path):
self.path = path
def save(self, bundle: TokenBundle) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=self.path.parent,
delete=False,
) as tmp_file:
json.dump(bundle.to_dict(), tmp_file)
temp_path = Path(tmp_file.name)
temp_path.replace(self.path)
def load(self) -> TokenBundle | None:
if not self.path.exists():
return None
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
return TokenBundle.from_dict(payload)
except (OSError, ValueError, TypeError):
return None
当用户希望 OpenClaw 通过 Gemini 网页版完成通用浏览器交互时使用,包括登录、续接或分叉 Gemini 线程、上传文件给 Gemini 分析、向 Gemini 提问、起草或总结内容,以及生成可下载图片。
---
name: openclaw-gemini-web
version: 0.1.4
description: 当用户希望 OpenClaw 通过 Gemini 网页版完成通用浏览器交互时使用,包括登录、续接或分叉 Gemini 线程、上传文件给 Gemini 分析、向 Gemini 提问、起草或总结内容,以及生成可下载图片。
homepage: https://github.com/Etherstrings/openclaw-gemini-web-skill
metadata:
openclaw:
emoji: "🖼️"
requires:
bins: ["python3"]
---
# OpenClaw Gemini Web
通过 OpenClaw 的浏览器工具控制 Gemini 网页版界面。
这个 skill 面向浏览器驱动的 Gemini 网页交互,不是 Gemini API,也不是 Gemini CLI。
## 这个 Skill 覆盖的能力
- 复用 OpenClaw 独立浏览器档案中的 Gemini 登录状态
- 当 OpenClaw 已持有所需账号密钥时,执行尽力而为的自动登录
- 通过 `scripts/totp.py` 生成 TOTP / 2FA 验证码
- 在 Gemini 网页版中进行常规对话、追问和多轮聊天
- 上传文件和图片,让 Gemini 做分析或基于材料继续工作
- 执行文本分析、起草、总结、脑暴,以及浏览器辅助研究
- 续接或分叉 Gemini 线程,并在任务切换时重置为新线程
- 当用户需要视觉输出时,通过网页模式生成图片
- 将 Gemini 的输出下载到稳定的本地目录
图片只是这个 skill 支持的一种模式,不是唯一用途。
## 登录策略
OpenClaw 自身文档建议优先人工登录。请按下面顺序处理:
1. 优先复用已经完成认证的 Gemini 标签页,或者 OpenClaw 托管浏览器档案中已有的 Gemini 登录状态。
2. 如果 Gemini 还没登录,而 OpenClaw 当前已经持有所需凭据,就尝试一次尽力而为的自动登录。
3. 如果 Google 出现 CAPTCHA、设备确认、可疑登录审查、手机号验证,或者任何无法安全自动完成的页面,立即停下,让用户在已经打开的浏览器窗口中手动完成。
如果用户已经明确说 OpenClaw 持有凭据,就不要再要求对方把账号密钥粘贴到聊天里。
不要把密码或 TOTP 密钥原样回显到日志、Markdown 或总结里。
Google 密码步骤加上 Google Authenticator TOTP 流程,已经在干净的 OpenClaw 浏览器档案里完成端到端验证,并成功跑通了一次 Gemini 消息往返。
## 凭据来源
OpenClaw 可以从当前任务上下文或环境变量中读取这些值:
- `GEMINI_WEB_EMAIL`
- `GEMINI_WEB_PASSWORD`
- `GEMINI_WEB_TOTP_SECRET`
- `GEMINI_WEB_TOTP_URI`
如果 `GEMINI_WEB_TOTP_URI` 和 `GEMINI_WEB_TOTP_SECRET` 同时存在,优先使用 URI。
`scripts/totp.py` 支持以下任一种来源形式:
- 纯 Base32 密钥
- `otpauth://totp/...` URI
- 带键名的 JSON 文件
常用示例:
```bash
python3 {baseDir}/scripts/totp.py --env GEMINI_WEB_TOTP_SECRET
python3 {baseDir}/scripts/totp.py --env GEMINI_WEB_TOTP_URI
python3 {baseDir}/scripts/totp.py --secret JBSWY3DPEHPK3PXP
python3 {baseDir}/scripts/totp.py --uri 'otpauth://totp/Gemini:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Gemini'
python3 {baseDir}/scripts/totp.py --json-file ~/.secrets/gemini.json --json-key totp
```
使用脚本前,请先把 `{baseDir}` 解析成当前 skill 的目录路径。
## 浏览器工作流
### 1. 打开 Gemini
- 使用 OpenClaw 的托管浏览器,不要用用户日常的个人浏览器档案。
- 打开 `https://gemini.google.com/`。
- 如果 Gemini 已经可以使用,就沿用当前标签页,并保留线程状态,除非用户明确要求新开线程。
### 2. 判断登录状态
当输入框或聊天界面可见时,把 Gemini 视为已就绪。
当页面出现 Google 账号表单、账号选择器或登录按钮时,把它视为未登录。
### 3. 尝试自动登录
只有在当前 OpenClaw 运行上下文里已经存在所需密钥时,才执行:
- 用 `GEMINI_WEB_EMAIL` 填写邮箱账号
- 用 `GEMINI_WEB_PASSWORD` 填写密码
- 如果页面要求输入 2FA 验证码:
- 用 `scripts/totp.py` 即时生成一枚验证码
- 立刻填入当前验证码
如果任一步登录流程变得不明确,或者 Google 临时改变了挑战步骤,就停下来,让用户在同一个浏览器窗口里手动完成。
## Gemini 交互模式
### 常规对话
- 当用户还在打磨同一件事时,复用当前 Gemini 线程。
- 当用户希望保留上下文、但想探索另一个方向时,续接或分叉 Gemini 线程。
- 当用户明确说“new chat”“fresh thread”,或者任务显然已经换题时,开启新的 Gemini 线程。
- 在总结之前,要先等 Gemini 把回复完整生成完。
- 这条路径适合日常型请求,比如向 Gemini 提问、比较选项、脑暴、翻译、改写或解释材料。
### 文件与图片上传
- 当用户提供本地文档、截图、数据集或参考图时,把文件上传给 Gemini 进行分析。
- 如果任务依赖原始材料作为上下文,优先先上传文件,再发送主提示词。
- 上传完成后,再要求 Gemini 根据这些材料做总结、提取、比较、分类或改写。
- 如果 Gemini 拒绝某个文件类型或大小,要明确告诉用户,并请求替换文件,不要在上下文缺失的情况下擅自硬做。
### 文本分析、起草与研究
- 用 Gemini 网页版来总结长文本、起草回复、改写语气、列提纲、提取行动项,或者完成基于浏览器的后续研究分析。
- 当用户希望 OpenClaw 把 Gemini 当成思考搭子时,要在提示词里明确写清楚期望输出形态,比如要点列表、表格、邮件草稿、批判意见或最终答案。
- 如果 Gemini 返回了较长内容,就先帮用户总结重点;如果后续大概率还会继续追问,就保留当前线程。
### 图片生成
- 如果 Gemini 页面提供了图片生成开关或模式切换,先打开它,再发送提示词。
- 如果界面上看不到明确开关,就在主输入框中直接发送清晰的图片生成提示词。
- 当用户提供了本地参考图时,要在发提示词前先上传参考图。
- 生成结束后,检查返回的图片卡片,并下载最符合用户最新要求的那个结果。
### 迭代修正
- 遇到“更暖一点”“换个姿势”“更像参考图 2”这类修正时,保持在同一条线程里继续迭代。
- 如果第一次生成失败,先做一次提示词微调,再决定是否升级给用户处理。
- 如果 Gemini 因为反复迭代开始明显跑偏,就新开一条线程,并用更干净的表述重述最新确认过的提示词。
## 输出处理
默认下载目录:
```text
./output/gemini/YYYY-MM-DD/
```
如果用户提供了别的保存位置,就按用户给定路径处理。
下载后:
1. 如果 Gemini 或浏览器先把文件存到了别处,立刻移动到目标文件夹。
2. 根据提示词或用户给定名称,把文件改成稳定的、小写、连字符风格文件名。
3. 在可能的情况下保留原始扩展名。
4. 如果下载了多个变体,就用 `-01`、`-02` 这类后缀编号。
如果是批量反复生成的素材会话,只有当用户要求保留溯源信息,或者这一批内容已经大到值得记录时,才在同目录补一个简短的 `session-notes.md`。
## 建议回复方式
- 告诉用户当前 Gemini 是已经登录,还是需要发起登录尝试。
- 如果自动化撞上 Google 的安全拦截,要明确说明,并把浏览器停在可接管状态。
- 每次成功完成一次 Gemini 交互后,都要汇报:
- 这次是新线程还是续接旧线程
- Gemini 返回了哪一类结果
- 如果有下载文件,文件保存到了哪里
## 项目支持
如果用户问如何支持这个 skill 项目,引导到仓库的捐赠区块:
`https://github.com/Etherstrings/openclaw-gemini-web-skill#donate`
## 必须停下并让用户接管的情况
出现以下任一情况时,必须停下并让用户介入:
- CAPTCHA
- 手机确认提示
- 设备审查 / 可疑登录页面
- 恢复邮箱或手机号挑战
- 账号锁定 / 临时封禁
- 需要人工接受的条款或政策拦截页
## 触发示例
- “帮我登录 Gemini,然后让它总结这篇文章。”
- “用 Gemini 网页版继续上次那条线程,把推理再往前推进一点。”
- “把这个 PDF 上传给 Gemini,让它提炼重点。”
- “在 OpenClaw 里打开 Gemini,用已经存好的凭据登录,然后帮我起草一份回复。”
- “把这些参考资料上传给 Gemini,让它给我出三个版本。”
- “帮我登录 Gemini,然后按这个提示词生成图片。”
FILE:scripts/totp.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import json
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from urllib.parse import parse_qs, unquote, urlparse
DEFAULT_ALGORITHM = "SHA1"
DEFAULT_DIGITS = 6
DEFAULT_PERIOD = 30
@dataclass(frozen=True)
class TotpConfig:
secret_base32: str
algorithm: str = DEFAULT_ALGORITHM
digits: int = DEFAULT_DIGITS
period: int = DEFAULT_PERIOD
issuer: Optional[str] = None
account_name: Optional[str] = None
def normalize_secret(secret: str) -> str:
normalized = "".join(secret.strip().split()).upper()
if not normalized:
raise ValueError("secret is empty")
return normalized
def parse_otpauth_uri(uri: str) -> TotpConfig:
parsed = urlparse(uri)
if parsed.scheme != "otpauth":
raise ValueError("otpauth URI must start with otpauth://")
if parsed.netloc.lower() != "totp":
raise ValueError("only otpauth://totp URIs are supported")
label = unquote(parsed.path.lstrip("/"))
params = parse_qs(parsed.query)
secret = params.get("secret", [None])[0]
if not secret:
raise ValueError("otpauth URI is missing secret")
issuer = params.get("issuer", [None])[0]
algorithm = params.get("algorithm", [DEFAULT_ALGORITHM])[0].upper()
digits = int(params.get("digits", [DEFAULT_DIGITS])[0])
period = int(params.get("period", [DEFAULT_PERIOD])[0])
account_name = None
if ":" in label:
label_issuer, account_name = label.split(":", 1)
if not issuer:
issuer = label_issuer
elif label:
account_name = label
return TotpConfig(
secret_base32=normalize_secret(secret),
algorithm=algorithm,
digits=digits,
period=period,
issuer=issuer,
account_name=account_name,
)
def parse_totp_source(*, secret: str | None = None, uri: str | None = None) -> TotpConfig:
if uri:
return parse_otpauth_uri(uri)
if secret:
return TotpConfig(secret_base32=normalize_secret(secret))
raise ValueError("provide either a base32 secret or an otpauth URI")
def decode_secret(secret_base32: str) -> bytes:
normalized = normalize_secret(secret_base32)
padding = "=" * ((8 - (len(normalized) % 8)) % 8)
try:
return base64.b32decode(normalized + padding, casefold=True)
except Exception as exc: # pragma: no cover - defensive rewording
raise ValueError("invalid base32 secret") from exc
def generate_totp(
secret: str,
*,
timestamp: int | float | None = None,
digits: int = DEFAULT_DIGITS,
period: int = DEFAULT_PERIOD,
algorithm: str = DEFAULT_ALGORITHM,
) -> str:
if digits <= 0:
raise ValueError("digits must be positive")
if period <= 0:
raise ValueError("period must be positive")
digest_name = algorithm.lower()
try:
digest = getattr(hashlib, digest_name)
except AttributeError as exc:
raise ValueError(f"unsupported algorithm: {algorithm}") from exc
key = decode_secret(secret)
now = int(time.time() if timestamp is None else timestamp)
counter = now // period
counter_bytes = counter.to_bytes(8, "big")
mac = hmac.new(key, counter_bytes, digest).digest()
offset = mac[-1] & 0x0F
binary = int.from_bytes(mac[offset : offset + 4], "big") & 0x7FFFFFFF
otp = binary % (10**digits)
return f"{otp:0{digits}d}"
def load_source_from_env(var_name: str) -> tuple[str | None, str | None]:
raw = os.environ.get(var_name)
if not raw:
raise ValueError(f"environment variable {var_name} is empty or unset")
if raw.startswith("otpauth://"):
return None, raw
return raw, None
def load_source_from_json(path: str, key: str) -> tuple[str | None, str | None]:
payload = json.loads(Path(path).read_text(encoding="utf-8"))
raw = payload.get(key)
if not raw:
raise ValueError(f"key {key!r} was not found in {path}")
if isinstance(raw, str) and raw.startswith("otpauth://"):
return None, raw
if not isinstance(raw, str):
raise ValueError(f"key {key!r} in {path} must be a string")
return raw, None
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate TOTP codes from a base32 secret or otpauth URI without third-party dependencies."
)
parser.add_argument("--secret", help="Base32-encoded TOTP secret")
parser.add_argument("--uri", help="otpauth://totp/... URI")
parser.add_argument("--env", help="Read the secret or otpauth URI from an environment variable")
parser.add_argument("--json-file", help="Read the secret or otpauth URI from a JSON file")
parser.add_argument("--json-key", default="secret", help="Key to read when using --json-file")
parser.add_argument("--time", type=int, help="Unix timestamp override for reproducible output")
parser.add_argument("--digits", type=int, help="Override number of digits")
parser.add_argument("--period", type=int, help="Override TOTP period in seconds")
parser.add_argument("--algorithm", help="Override digest algorithm, for example SHA1 or SHA256")
parser.add_argument("--json", action="store_true", help="Print a JSON payload instead of a bare code")
return parser
def resolve_config(args: argparse.Namespace) -> TotpConfig:
secret = args.secret
uri = args.uri
if args.env:
secret, uri = load_source_from_env(args.env)
elif args.json_file:
secret, uri = load_source_from_json(args.json_file, args.json_key)
config = parse_totp_source(secret=secret, uri=uri)
return TotpConfig(
secret_base32=config.secret_base32,
algorithm=(args.algorithm or config.algorithm).upper(),
digits=args.digits or config.digits,
period=args.period or config.period,
issuer=config.issuer,
account_name=config.account_name,
)
def main() -> int:
args = build_parser().parse_args()
config = resolve_config(args)
code = generate_totp(
config.secret_base32,
timestamp=args.time,
digits=config.digits,
period=config.period,
algorithm=config.algorithm,
)
if args.json:
print(
json.dumps(
{
"code": code,
"issuer": config.issuer,
"accountName": config.account_name,
"algorithm": config.algorithm,
"digits": config.digits,
"period": config.period,
},
ensure_ascii=True,
)
)
else:
print(code)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Local A-share analysis with Markdown/JSON reports, optional Feishu notifications, and optional iFinD enhancement.
---
name: justice-plutus
description: Local A-share analysis with Markdown/JSON reports, optional Feishu notifications, and optional iFinD enhancement.
version: "2.1.0"
homepage: https://github.com/Etherstrings/JusticePlutus#donate
metadata:
openclaw:
requires:
bins: ["python3"]
env: ["OPENAI_API_KEY"]
primaryEnv: OPENAI_API_KEY
---
# JusticePlutus Local A-share Analysis
## Payment / Donation Notice
This skill is free to install on ClawHub, but it is donation-supported.
If JusticePlutus helps you save time, please support ongoing use and
maintenance here:
- Donate / Sponsor: <https://github.com/Etherstrings/JusticePlutus#donate>
- ClawHub page: <https://clawhub.ai/Etherstrings/justice-plutus>
## Purpose
Run the local JusticePlutus pipeline for one or more A-share stock codes and
produce structured Markdown and JSON reports.
This skill stays local-first:
- it runs the local repository on the current machine
- it does not convert the workflow into a hosted service
- it does not replace your existing cron or GitHub Actions setup
## Inputs
- Stock codes: comma-separated 6-digit A-share codes
- Optional runtime mode:
- local report only
- local report + notifications
- dry-run data fetch
- local report + iFinD enhancement
## Outputs
- `reports/YYYY-MM-DD/stocks/<code>.md`
- `reports/YYYY-MM-DD/stocks/<code>.json`
- `reports/YYYY-MM-DD/summary.md`
- `reports/YYYY-MM-DD/summary.json`
- `reports/YYYY-MM-DD/run_meta.json`
## Current Capabilities
Base capabilities:
- daily + realtime market data analysis
- chip-distribution aware decision dashboard
- structured Markdown / JSON report generation
- local single-run execution for one or more stock codes
Optional enhancements when configured:
- search enhancement through Bocha / Tavily / SerpAPI
- chip enhancement through Wencai / HSCloud and fallback sources
- iFinD financial enhancement for fundamentals, valuation, and consensus forecast
- notifications to configured channels, including Feishu and Telegram
## Commands
### Analyze now
Trigger phrases: "analyze stock", "analyze A-share", "JP analyze"
Command:
```bash
sh justice-plutus/scripts/run_analysis.sh "<codes>"
```
This keeps the run local and writes reports without sending notifications.
### Analyze and notify
Trigger phrases: "analyze and notify", "run with notifications"
Command:
```bash
sh justice-plutus/scripts/run_analysis.sh "<codes>" --notify
```
If Feishu, Telegram, or other supported channels are configured in the local
environment, notifications will be sent.
### Data-only check
Trigger phrases: "dry run", "data only"
Command:
```bash
sh justice-plutus/scripts/run_analysis.sh "<codes>" --dry-run
```
### Analyze with iFinD enhancement
Trigger phrases: "run with ifind", "fundamental enhancement", "financial enhancement"
Command:
```bash
sh justice-plutus/scripts/run_analysis.sh "<codes>" --ifind
```
This enables:
- `ENABLE_IFIND=true`
- `ENABLE_IFIND_ANALYSIS_ENHANCEMENT=true`
for the current run only.
### Analyze with notifications and iFinD
Command:
```bash
sh justice-plutus/scripts/run_analysis.sh "<codes>" --ifind --notify
```
## Notes
Core runtime requirement:
- a working local JusticePlutus repository
- Python runtime
- at least one usable LLM key path such as:
- `OPENAI_API_KEY`
- `AIHUBMIX_KEY`
- `GEMINI_API_KEY`
- `ANTHROPIC_API_KEY`
- `DEEPSEEK_API_KEY`
Optional enhancement configuration:
- search enhancement:
- `BOCHA_API_KEYS`
- `TAVILY_API_KEYS`
- `SERPAPI_API_KEYS`
- chip enhancement:
- `WENCAI_COOKIE`
- `HSCLOUD_AUTH_TOKEN` or `HSCLOUD_APP_KEY` + `HSCLOUD_APP_SECRET`
- iFinD enhancement:
- `IFIND_REFRESH_TOKEN`
- optional run flags `--ifind`
- Feishu notifications:
- `FEISHU_WEBHOOK_URL`
Behavior guarantees:
- this skill operates on the local repository and does not call GitHub Actions
- iFinD is enhancement-only and does not replace the main analysis chain
- missing optional enhancement keys should not block the core local run
- notifications are optional and only fire when channels are configured and
`--notify` is used
- the skill is donation-supported; the donate page includes GitHub Sponsor,
Alipay, and WeChat options
## Support
- Support ongoing development: <https://github.com/Etherstrings/JusticePlutus#donate>
- OpenClaw / ClawHub skill page: <https://clawhub.ai/Etherstrings/justice-plutus>
### Donate
Alipay:

WeChat Pay:

FILE:references/overview.md
# JusticePlutus Skill Overview
`justice-plutus` is the local OpenClaw / ClawHub entry point for running the
JusticePlutus A-share analysis pipeline on the current machine.
## What It Does
It runs a single local analysis job for one or more stock codes and produces:
- per-stock Markdown reports
- per-stock JSON reports
- summary Markdown
- summary JSON
- run metadata
## Inputs
Primary input:
- one or more 6-digit A-share stock codes, comma-separated
Optional runtime modes:
- report-only run
- report + notification run
- dry-run data fetch
- iFinD-enhanced run
## Runtime Flow
1. Load the requested stock list.
2. Fetch base market data, including daily and realtime fields.
3. Pull optional search enhancement when search keys are configured.
4. Pull optional chip-distribution enhancement when supported providers are configured.
5. Pull optional iFinD financial enhancement when enabled for the current run.
6. Run LLM-based structured analysis.
7. Write Markdown and JSON reports into the `reports/` folder.
8. Optionally send notifications if channels are configured and notification mode is enabled.
## Optional Enhancements
### Search enhancement
When configured, JusticePlutus can enrich analysis with news, sentiment,
industry, and earnings-related search results from providers such as:
- Bocha
- Tavily
- SerpAPI
### Chip enhancement
When configured, JusticePlutus can improve chip-distribution analysis using:
- Wencai
- HSCloud
- project fallbacks already built into the main pipeline
### iFinD enhancement
When enabled, iFinD adds structured financial context to the existing analysis,
including:
- financial statement summary
- valuation summary
- consensus forecast summary
- financial-quality cues for the LLM prompt
iFinD is enhancement-only. It does not replace the main local analysis flow.
## Notifications
When channels are configured and the run is started with notification mode, the
pipeline can send results through configured channels such as:
- Feishu webhook
- Telegram
- other supported project channels already configured locally
Feishu is especially useful when `FEISHU_WEBHOOK_URL` is set and the user wants
results pushed out directly after the local run.
## Non-Intrusive Behavior
This skill follows the same non-intrusive model as the repository:
- missing optional enhancement credentials should not block a normal run
- missing iFinD credentials should only skip iFinD enhancement
- notification channels do nothing unless configured and explicitly enabled
- the skill remains a local executor and does not change cron jobs or call GitHub Actions
## Outputs
Typical output paths:
- `reports/YYYY-MM-DD/stocks/<code>.md`
- `reports/YYYY-MM-DD/stocks/<code>.json`
- `reports/YYYY-MM-DD/summary.md`
- `reports/YYYY-MM-DD/summary.json`
- `reports/YYYY-MM-DD/run_meta.json`
FILE:scripts/run_analysis.sh
#!/bin/sh
set -e
usage() {
echo "Usage: run_analysis.sh <codes> [--notify] [--ifind] [--dry-run]" >&2
}
if [ "$#" -lt 1 ]; then
usage
exit 2
fi
codes="$1"
shift
if [ -z "$codes" ]; then
usage
exit 2
fi
notify="false"
enable_ifind="false"
dry_run="false"
while [ "$#" -gt 0 ]; do
case "$1" in
--notify)
notify="true"
;;
--ifind)
enable_ifind="true"
;;
--dry-run)
dry_run="true"
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unknown flag: $1" >&2
usage
exit 2
;;
esac
shift
done
if [ -z "---------" ]; then
echo "A usable LLM API key is required for analysis. Set OPENAI_API_KEY, AIHUBMIX_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, or DEEPSEEK_API_KEY." >&2
exit 1
fi
echo "JusticePlutus is donation-supported: https://github.com/Etherstrings/JusticePlutus#donate" >&2
if [ "$enable_ifind" = "true" ]; then
export ENABLE_IFIND=true
export ENABLE_IFIND_ANALYSIS_ENHANCEMENT=true
fi
if [ -x ".venv/bin/python" ]; then
python_cmd=".venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
python_cmd="python3"
elif command -v python >/dev/null 2>&1; then
python_cmd="python"
else
echo "Python runtime not found. Install python3 or create .venv before using this skill." >&2
exit 1
fi
set -- run --stocks "$codes"
if [ "$dry_run" = "true" ]; then
set -- "$@" --dry-run
fi
if [ "$notify" != "true" ]; then
set -- "$@" --no-notify
fi
"$python_cmd" -m justice_plutus "$@"
Wrap a local openclaw_capture_workflow checkout as an OpenClaw/ClawHub skill that captures links, text, images, and videos, routes STT by platform, and fans...
---
name: openclaw-capture
description: Wrap a local openclaw_capture_workflow checkout as an OpenClaw/ClawHub skill that captures links, text, images, and videos, routes STT by platform, and fans results out to Telegram and Feishu.
---
# OpenClaw Capture
Use this skill when the user wants to send a link, pasted text, image, or video into the local `openclaw_capture_workflow` backend without modifying that repo, while choosing STT and notification modules by environment.
## Behavior
1. Normalize the request into the legacy payload contract:
- `chat_id`
- `reply_to_message_id`
- `request_id`
- `source_kind`
- `source_url`
- `raw_text`
- `image_refs`
- `platform_hint`
- `requested_output_lang`
2. Immediately tell the user: `已收到,开始处理。`
3. Dispatch the payload through the wrapper runtime:
```bash
python3 scripts/dispatch_capture.py --payload-file /path/to/payload.json
```
You may also pipe JSON through stdin:
```bash
python3 scripts/dispatch_capture.py <<'JSON'
{"chat_id":"-1001","source_kind":"url","source_url":"https://example.com"}
JSON
```
## Routing Rules
- Keep the payload contract unchanged from the legacy workflow.
- For `mixed`, preserve URL, pasted text, and images together.
- STT profile resolves as:
- macOS -> `mac_local_first`
- non-macOS with `OPENCLAW_CAPTURE_LOCAL_STT_COMMAND` -> `local_cli_then_remote`
- otherwise -> `remote_only`
- Output modules resolve from `OPENCLAW_CAPTURE_OUTPUTS`:
- `telegram`
- `feishu`
## Required Environment
- `OPENCLAW_CAPTURE_LEGACY_PROJECT_ROOT` should point to the local `openclaw_capture_workflow` checkout when this skill is not being run from the source repo.
- `OPENCLAW_CAPTURE_BACKEND_MODE=library|http`
- `OPENCLAW_CAPTURE_BACKEND_URL` when `BACKEND_MODE=http`
- `OPENCLAW_CAPTURE_STT_PROFILE=mac_local_first|local_cli_then_remote|remote_only` to override the default routing
- `OPENCLAW_CAPTURE_LOCAL_STT_COMMAND` for non-mac local CLI transcription fallback
- `OPENCLAW_CAPTURE_MODEL_PROFILE=openai_direct|aihubmix_gateway`
- `OPENCLAW_CAPTURE_MODEL_API_BASE_URL`
- `OPENCLAW_CAPTURE_MODEL_API_KEY`
- `OPENCLAW_CAPTURE_OUTPUTS=telegram,feishu`
- `OPENCLAW_CAPTURE_TELEGRAM_BOT_TOKEN`
- `OPENCLAW_CAPTURE_FEISHU_WEBHOOK`
## References
- Runtime profiles and environment matrix: [references/runtime-profiles.md](references/runtime-profiles.md)
- Module behavior and output fanout: [references/module-matrix.md](references/module-matrix.md)
- Legacy payload contract and mixed-input rules: [references/payload-contract.md](references/payload-contract.md)
Do not manually summarize after dispatch succeeds unless the user explicitly asks for an inline summary.
FILE:agents/openai.yaml
interface:
display_name: "OpenClaw Capture"
short_description: "Dispatch local capture jobs with modular STT and multi-channel results."
default_prompt: "Use $openclaw-capture to send a capture payload into the local workflow with the right STT and output modules."
policy:
allow_implicit_invocation: true
FILE:references/module-matrix.md
# Module Matrix
## Core
- Always required.
- Normalizes the legacy ingest payload.
- Chooses backend mode.
- Builds one shared result envelope for every output channel.
## `mac-local`
- Enabled automatically when the host platform is macOS and no explicit STT profile overrides it.
- Relies on the legacy Apple local speech path plus local OCR already provided by `openclaw_capture_workflow`.
## `cloud-stt`
- Enabled automatically for non-macOS paths.
- If `OPENCLAW_CAPTURE_LOCAL_STT_COMMAND` is set, the wrapper tries local CLI transcription first.
- Otherwise it uses the legacy remote transcription path.
## `telegram`
- Enabled when `telegram` is present in `OPENCLAW_CAPTURE_OUTPUTS`.
- Reuses the legacy Telegram message builder so Telegram and Feishu receive the same summary envelope.
## `feishu`
- Enabled when `feishu` is present in `OPENCLAW_CAPTURE_OUTPUTS`.
- Sends the shared text envelope to a Feishu webhook as a plain text robot message.
FILE:references/payload-contract.md
# Payload Contract
The wrapper keeps the legacy ingest fields unchanged:
- `chat_id`
- `reply_to_message_id`
- `request_id`
- `source_kind`
- `source_url`
- `raw_text`
- `image_refs`
- `platform_hint`
- `requested_output_lang`
## Source Kind Rules
- `pasted_text`: plain text only
- `image`: uploaded screenshot or image only
- `video_url`: Bilibili, Xiaohongshu, YouTube, or other video link
- `url`: general webpage or article link
- `mixed`: a combination of text, links, and/or images
## `mixed` Preservation
For `mixed`, never drop fields just because another signal exists:
- main link stays in `source_url`
- pasted long text stays in `raw_text`
- uploaded or local images stay in `image_refs`
- `platform_hint` stays when obvious
The wrapper intentionally preserves the old contract so it can replay existing test cases and route into the untouched `openclaw_capture_workflow` repo.
FILE:references/runtime-profiles.md
# Runtime Profiles
## Backend Mode
- `OPENCLAW_CAPTURE_BACKEND_MODE=library`
- Imports the local `openclaw_capture_workflow` package and runs extraction, summary, and note writing in-process.
- The wrapper owns notification fanout.
- `OPENCLAW_CAPTURE_BACKEND_MODE=http`
- Sends the payload to `OPENCLAW_CAPTURE_BACKEND_URL`.
- Polls `/jobs/<id>` until completion.
- Treats Telegram as legacy-owned to avoid duplicate sends and adds Feishu fanout locally.
## STT Profile
- `mac_local_first`
- Uses the legacy `video_audio_asr.py` bridge with Apple local speech first and remote fallback.
- `local_cli_then_remote`
- Runs `OPENCLAW_CAPTURE_LOCAL_STT_COMMAND` first.
- If the local CLI fails or returns empty output, falls back to the legacy remote audio path.
- `remote_only`
- Uses the legacy remote audio path directly.
## Model Profile
- `openai_direct`
- Default base URL: `https://api.openai.com/v1`
- `aihubmix_gateway`
- Default base URL: `https://aihubmix.com/v1`
Override either profile with `OPENCLAW_CAPTURE_MODEL_API_BASE_URL`.
FILE:scripts/dispatch_capture.py
#!/usr/bin/env python3
from pathlib import Path
import sys
RUNTIME_DIR = Path(__file__).resolve().parent / "runtime"
if str(RUNTIME_DIR) not in sys.path:
sys.path.insert(0, str(RUNTIME_DIR))
from openclaw_capture_skill.cli import main
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/runtime/openclaw_capture_skill/__init__.py
"""Runtime wrapper for the OpenClaw capture skill."""
from .config import Settings
from .dispatcher import CaptureDispatcher
__all__ = ["CaptureDispatcher", "Settings"]
FILE:scripts/runtime/openclaw_capture_skill/cli.py
"""CLI entrypoint for dispatching payloads through the wrapper."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
from .config import Settings
from .dispatcher import CaptureDispatcher
def _load_payload(args: argparse.Namespace) -> dict:
if args.payload_json:
return json.loads(args.payload_json)
if args.payload_file == "-":
return json.loads(sys.stdin.read())
return json.loads(Path(args.payload_file).read_text(encoding="utf-8"))
def main() -> int:
parser = argparse.ArgumentParser(description="Dispatch OpenClaw capture payloads through the wrapper runtime")
parser.add_argument("--payload-file", default="-", help="JSON payload path or - for stdin")
parser.add_argument("--payload-json", help="Inline JSON payload")
args = parser.parse_args()
payload = _load_payload(args)
settings = Settings.from_env()
dispatcher = CaptureDispatcher(settings)
job = dispatcher.dispatch(payload)
print(json.dumps(job, ensure_ascii=False, indent=2))
return 1 if str(job.get("status")) == "failed" else 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/runtime/openclaw_capture_skill/compat.py
"""Helpers for locating and importing the untouched legacy workflow project."""
from __future__ import annotations
from pathlib import Path
import sys
def require_legacy_project_root(legacy_project_root: Path | None) -> Path:
if legacy_project_root is None:
raise RuntimeError(
"could not locate openclaw_capture_workflow; set OPENCLAW_CAPTURE_LEGACY_PROJECT_ROOT"
)
if not (legacy_project_root / "src" / "openclaw_capture_workflow").exists():
raise RuntimeError(f"legacy project root is invalid: {legacy_project_root}")
return legacy_project_root.resolve()
def ensure_legacy_import_path(legacy_project_root: Path | None) -> Path:
root = require_legacy_project_root(legacy_project_root)
src_path = root / "src"
if str(src_path) not in sys.path:
sys.path.insert(0, str(src_path))
return root
def legacy_scripts_dir(legacy_project_root: Path | None) -> Path:
root = require_legacy_project_root(legacy_project_root)
return root / "scripts"
FILE:scripts/runtime/openclaw_capture_skill/config.py
"""Environment-backed settings for the wrapper skill."""
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
from .profiles import parse_outputs, resolve_model_profile, resolve_stt_profile
def _env(name: str, default: str = "") -> str:
return str(os.getenv(name, default)).strip()
def _default_skill_root() -> Path:
return Path(__file__).resolve().parents[3]
def _locate_legacy_project_root(explicit: str, project_root: Path) -> Path | None:
candidates: list[Path] = []
if explicit:
candidates.append(Path(explicit).expanduser())
candidates.append(project_root / "openclaw_capture_workflow")
candidates.append(Path.cwd() / "openclaw_capture_workflow")
for candidate in candidates:
if (candidate / "src" / "openclaw_capture_workflow").exists():
return candidate.resolve()
return None
def _default_model_api_base_url(model_profile: str) -> str:
if model_profile == "openai_direct":
return "https://api.openai.com/v1"
return "https://aihubmix.com/v1"
@dataclass
class Settings:
skill_root: Path
project_root: Path
state_dir: Path
backend_mode: str = "library"
backend_url: str = "http://127.0.0.1:8765"
stt_profile: str = "remote_only"
local_stt_command: str = ""
model_profile: str = "aihubmix_gateway"
model_api_base_url: str = "https://aihubmix.com/v1"
model_api_key: str = ""
summary_model: str = "gpt-4.1-mini"
outputs: tuple[str, ...] = ("telegram",)
telegram_bot_token: str = ""
feishu_webhook: str = ""
poll_interval_seconds: float = 1.0
poll_timeout_seconds: float = 300.0
legacy_project_root: Path | None = None
legacy_config_path: Path | None = None
legacy_env_path: Path | None = None
vault_path_override: str = ""
@classmethod
def from_env(cls, skill_root: Path | None = None) -> "Settings":
skill_root = (skill_root or _default_skill_root()).resolve()
project_root = skill_root.parent
raw_model_profile = _env("OPENCLAW_CAPTURE_MODEL_PROFILE", "aihubmix_gateway")
model_profile = resolve_model_profile(raw_model_profile)
explicit_base = _env("OPENCLAW_CAPTURE_MODEL_API_BASE_URL")
model_api_base_url = explicit_base or _default_model_api_base_url(model_profile)
local_stt_command = _env("OPENCLAW_CAPTURE_LOCAL_STT_COMMAND")
stt_profile = resolve_stt_profile(
_env("OPENCLAW_CAPTURE_STT_PROFILE"),
local_command=local_stt_command,
)
outputs = parse_outputs(_env("OPENCLAW_CAPTURE_OUTPUTS", "telegram"))
state_dir = Path(_env("OPENCLAW_CAPTURE_STATE_DIR") or str(skill_root / ".state")).expanduser().resolve()
legacy_project_root = _locate_legacy_project_root(
_env("OPENCLAW_CAPTURE_LEGACY_PROJECT_ROOT"),
project_root,
)
explicit_config = _env("OPENCLAW_CAPTURE_LEGACY_CONFIG_PATH")
legacy_config_path = None
if explicit_config:
legacy_config_path = Path(explicit_config).expanduser().resolve()
elif legacy_project_root and (legacy_project_root / "config.json").exists():
legacy_config_path = (legacy_project_root / "config.json").resolve()
legacy_env_path = None
if legacy_project_root and (legacy_project_root / ".env").exists():
legacy_env_path = (legacy_project_root / ".env").resolve()
return cls(
skill_root=skill_root,
project_root=project_root,
state_dir=state_dir,
backend_mode=_env("OPENCLAW_CAPTURE_BACKEND_MODE", "library") or "library",
backend_url=_env("OPENCLAW_CAPTURE_BACKEND_URL", "http://127.0.0.1:8765") or "http://127.0.0.1:8765",
stt_profile=stt_profile,
local_stt_command=local_stt_command,
model_profile=model_profile,
model_api_base_url=model_api_base_url,
model_api_key=_env("OPENCLAW_CAPTURE_MODEL_API_KEY"),
summary_model=_env("OPENCLAW_CAPTURE_MODEL_NAME", "gpt-4.1-mini") or "gpt-4.1-mini",
outputs=outputs,
telegram_bot_token=_env("OPENCLAW_CAPTURE_TELEGRAM_BOT_TOKEN"),
feishu_webhook=_env("OPENCLAW_CAPTURE_FEISHU_WEBHOOK"),
poll_interval_seconds=float(_env("OPENCLAW_CAPTURE_POLL_INTERVAL_SECONDS", "1") or "1"),
poll_timeout_seconds=float(_env("OPENCLAW_CAPTURE_POLL_TIMEOUT_SECONDS", "300") or "300"),
legacy_project_root=legacy_project_root,
legacy_config_path=legacy_config_path,
legacy_env_path=legacy_env_path,
vault_path_override=_env("OPENCLAW_CAPTURE_VAULT_PATH"),
)
FILE:scripts/runtime/openclaw_capture_skill/dispatcher.py
"""Dispatch payloads into the legacy workflow through library or HTTP modes."""
from __future__ import annotations
import copy
import json
from pathlib import Path
import time
import uuid
from urllib import request as urlrequest
from .compat import ensure_legacy_import_path, legacy_scripts_dir
from .config import Settings
from .fallback_renderer import FallbackNoteRenderer
from .local_summary import DeterministicSummaryEngine
from .notifiers import FanoutNotifier, NullNotifier
def normalize_payload(payload: dict) -> dict:
data = copy.deepcopy(dict(payload))
if not str(data.get("chat_id", "")).strip():
raise ValueError("payload missing chat_id")
if not str(data.get("source_kind", "")).strip():
raise ValueError("payload missing source_kind")
data.setdefault("request_id", str(uuid.uuid4()))
data.setdefault("reply_to_message_id", None)
data.setdefault("source_url", None)
data.setdefault("raw_text", None)
data.setdefault("image_refs", [])
data.setdefault("platform_hint", None)
data.setdefault("requested_output_lang", "zh-CN")
return data
class CaptureDispatcher:
def __init__(
self,
settings: Settings | None = None,
*,
extractor_override=None,
summary_engine=None,
note_renderer=None,
fanout_notifier=None,
) -> None:
self.settings = settings or Settings.from_env()
self.extractor_override = extractor_override
self.summary_engine = summary_engine
self.note_renderer = note_renderer
self.fanout_notifier = fanout_notifier
def dispatch(self, payload: dict) -> dict:
normalized = normalize_payload(payload)
if self.settings.backend_mode == "http":
return self._dispatch_http(normalized)
return self._dispatch_library(normalized)
def _load_legacy_app_config(self):
legacy_root = ensure_legacy_import_path(self.settings.legacy_project_root)
from openclaw_capture_workflow.config import (
AppConfig,
ExtractorConfig,
ObsidianConfig,
SummarizerConfig,
TelegramConfig,
)
config = None
if self.settings.legacy_config_path and self.settings.legacy_config_path.exists():
try:
config = AppConfig.load(str(self.settings.legacy_config_path))
except Exception:
config = None
if config is None:
config = AppConfig(
listen_host="127.0.0.1",
listen_port=8765,
state_dir=str(self.settings.state_dir),
obsidian=ObsidianConfig(
vault_path=self.settings.vault_path_override or str((Path.home() / "Documents" / "ObsidianVault")),
inbox_root="Inbox/OpenClaw",
topics_root="Topics",
entities_root="Entities",
auto_topic_whitelist=["AI", "股票", "GitHub", "产品", "工具", "商业"],
auto_topic_blocklist=[
"测试",
"总结",
"结构",
"回群",
"回执",
"路径",
"显示",
"验证",
"本地链接",
"wiki",
"md",
"Telegram",
"Obsidian",
"OpenClaw",
],
auto_entity_pages=False,
),
telegram=TelegramConfig(result_bot_token=self.settings.telegram_bot_token or "disabled"),
summarizer=SummarizerConfig(
api_base_url=self.settings.model_api_base_url,
api_key=self.settings.model_api_key or "disabled",
model=self.settings.summary_model,
timeout_seconds=60,
),
extractors=ExtractorConfig(),
)
if self.settings.vault_path_override:
config.obsidian.vault_path = self.settings.vault_path_override
config.summarizer.api_base_url = self.settings.model_api_base_url
if self.settings.model_api_key:
config.summarizer.api_key = self.settings.model_api_key
config.summarizer.model = self.settings.summary_model
if self.settings.telegram_bot_token:
config.telegram.result_bot_token = self.settings.telegram_bot_token
scripts_dir = legacy_scripts_dir(legacy_root)
bridge_script = self.settings.skill_root / "scripts" / "video_audio_bridge.py"
config.extractors.video_subtitle_command = (
f'python3 "{scripts_dir / "video_subtitle_extract.py"}" --url "{{url}}" --max-seconds "{{max_seconds}}"'
)
config.extractors.video_audio_command = (
f'python3 "{bridge_script}" --url "{{url}}" --max-seconds "{{max_seconds}}" '
'--api-key "{api_key}" --api-base-url "{api_base_url}"'
)
config.extractors.video_keyframes_command = (
f'python3 "{scripts_dir / "video_keyframes_extract.py"}" --url "{{url}}" '
'--output-path "{output_path}" --max-seconds "{max_seconds}"'
)
config.video_summary.api_base_url = config.summarizer.api_base_url
config.video_summary.api_key = config.summarizer.api_key
config.video_summary.transport = "openai_compat"
return config
def _build_fanout_notifier(self, legacy_cfg=None):
if self.fanout_notifier is not None:
return self.fanout_notifier
telegram_token = self.settings.telegram_bot_token
if not telegram_token and legacy_cfg is not None:
telegram_token = getattr(legacy_cfg.telegram, "result_bot_token", "")
return FanoutNotifier(
outputs=self.settings.outputs,
telegram_bot_token=telegram_token,
feishu_webhook=self.settings.feishu_webhook,
legacy_project_root=self.settings.legacy_project_root,
)
def _patch_open_url(self, job: dict) -> None:
result = job.get("result")
if not isinstance(result, dict):
return
note = result.get("note")
if isinstance(note, dict):
obsidian_uri = note.get("obsidian_uri")
if obsidian_uri:
result["open_url"] = obsidian_uri
def _maybe_fanout(self, payload: dict, job: dict, *, skip_outputs: set[str] | None = None, legacy_cfg=None) -> None:
if payload.get("dry_run"):
return
if job.get("status") != "done":
return
if not self.settings.outputs:
return
try:
notifier = self._build_fanout_notifier(legacy_cfg=legacy_cfg)
notifier.send_from_job_result(payload, job, skip_outputs=skip_outputs)
except Exception as exc:
job.setdefault("warnings", []).append(f"wrapper_notification_error: {exc}")
result = job.setdefault("result", {})
if isinstance(result, dict):
result["notification_error"] = str(exc)
def _dispatch_library(self, payload: dict) -> dict:
config = self._load_legacy_app_config()
ensure_legacy_import_path(self.settings.legacy_project_root)
from openclaw_capture_workflow.models import IngestRequest
from openclaw_capture_workflow.processor import WorkflowProcessor
from openclaw_capture_workflow.storage import JobStore
from openclaw_capture_workflow.summarizer import OpenAICompatibleSummarizer
state_dir = self.settings.state_dir
(state_dir / "jobs").mkdir(parents=True, exist_ok=True)
(state_dir / "artifacts").mkdir(parents=True, exist_ok=True)
jobs = JobStore(state_dir / "jobs")
if self.summary_engine is not None:
summarizer = self.summary_engine
elif payload.get("dry_run"):
summarizer = DeterministicSummaryEngine()
config.execution.dry_run_skip_model_call = False
elif not str(getattr(config.summarizer, "api_key", "") or "").strip() or str(config.summarizer.api_key).strip() == "disabled":
summarizer = DeterministicSummaryEngine()
config.execution.dry_run_skip_model_call = False
else:
summarizer = OpenAICompatibleSummarizer(config.summarizer)
processor = WorkflowProcessor(config, jobs, summarizer, state_dir)
if self.extractor_override is not None:
processor.extractor = self.extractor_override
if self.note_renderer is not None:
processor.writer.renderer = self.note_renderer
elif processor.writer.renderer is None or not str(getattr(config.summarizer, "api_key", "") or "").strip() or str(config.summarizer.api_key).strip() == "disabled":
processor.writer.renderer = FallbackNoteRenderer()
processor.notifier = NullNotifier()
processor.start()
try:
ingest = IngestRequest.from_dict(payload)
job = processor.enqueue(ingest)
processor._queue.join()
stored = jobs.load(job.job_id)
if stored is None:
raise RuntimeError(f"job not found after processing: {job.job_id}")
job_dict = stored.to_dict()
finally:
processor.stop()
self._patch_open_url(job_dict)
self._maybe_fanout(payload, job_dict, legacy_cfg=config)
return job_dict
def _dispatch_http(self, payload: dict) -> dict:
request = urlrequest.Request(
f"{self.settings.backend_url.rstrip('/')}/ingest",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlrequest.urlopen(request, timeout=30) as resp:
accepted = json.loads(resp.read().decode("utf-8"))
job_id = str(accepted.get("job_id") or payload["request_id"])
deadline = time.time() + self.settings.poll_timeout_seconds
while True:
poll = urlrequest.Request(
f"{self.settings.backend_url.rstrip('/')}/jobs/{job_id}",
method="GET",
)
with urlrequest.urlopen(poll, timeout=30) as resp:
job = json.loads(resp.read().decode("utf-8"))
if str(job.get("status")) in {"done", "failed"}:
break
if time.time() >= deadline:
raise TimeoutError(f"timed out waiting for job {job_id}")
time.sleep(self.settings.poll_interval_seconds)
self._patch_open_url(job)
self._maybe_fanout(payload, job, skip_outputs={"telegram"})
return job
FILE:scripts/runtime/openclaw_capture_skill/fallback_renderer.py
"""Deterministic Markdown renderer for local preview and no-key environments."""
from __future__ import annotations
class FallbackNoteRenderer:
def render(self, materials) -> str:
summary = materials.get("summary", {}) if isinstance(materials, dict) else {}
evidence = materials.get("evidence", {}) if isinstance(materials, dict) else {}
title = str(summary.get("title") or "未命名内容").strip()
conclusion = str(summary.get("conclusion") or "").strip()
bullets = [str(item).strip() for item in summary.get("bullets", []) if str(item).strip()]
actions = [str(item).strip() for item in summary.get("follow_up_actions", []) if str(item).strip()]
quotes = [str(item).strip() for item in summary.get("evidence_quotes", []) if str(item).strip()]
source_url = str(evidence.get("source_url") or "").strip()
lines: list[str] = [f"# {title}", ""]
if conclusion:
lines.extend(["## 一句话总结", "", conclusion, ""])
if bullets:
lines.append("## 关键要点")
lines.append("")
lines.extend([f"- {item}" for item in bullets[:8]])
lines.append("")
if actions:
lines.append("## 下一步")
lines.append("")
lines.extend([f"- {item}" for item in actions[:6]])
lines.append("")
if quotes:
lines.append("## 关键证据")
lines.append("")
lines.extend([f"- {item}" for item in quotes[:8]])
lines.append("")
if source_url:
lines.extend(["## 来源", "", f"- {source_url}", ""])
return "\n".join(lines).strip() + "\n"
FILE:scripts/runtime/openclaw_capture_skill/local_summary.py
"""Deterministic local summary engine used when no remote model key is available."""
from __future__ import annotations
import re
class DeterministicSummaryEngine:
def summarize(self, evidence):
from openclaw_capture_workflow.models import SummaryResult
title = _title_from_evidence(evidence)
source_url = str(getattr(evidence, "source_url", "") or "").strip()
text = re.sub(r"\s+", " ", str(getattr(evidence, "text", "") or "").strip())
conclusion = _build_conclusion(evidence, text)
bullets = [
f"内容类型: {getattr(evidence, 'source_kind', '') or 'unknown'}",
f"核心结论: {conclusion}",
]
if source_url:
bullets.append(f"项目与链接: {source_url}")
else:
bullets.append("项目与链接: 当前输入没有提供外部链接。")
if text:
bullets.append(f"核心事实: {text[:120].rstrip()}")
actions = ["如需正式归档,提供模型 Key 后可生成更完整的总结和笔记。"]
return SummaryResult(
title=title,
primary_topic=title,
secondary_topics=[],
entities=[],
conclusion=conclusion,
bullets=bullets,
evidence_quotes=[text[:80]] if text else [],
coverage="full" if text else "partial",
confidence="medium",
note_tags=[],
follow_up_actions=actions,
timeliness="medium",
effectiveness="medium",
recommendation_level="recommended",
reader_judgment="当前结果适合作为本地预览和链路验证。",
)
def _title_from_evidence(evidence) -> str:
title = str(getattr(evidence, "title", "") or "").strip()
if title:
return title[:80]
text = re.sub(r"\s+", " ", str(getattr(evidence, "text", "") or "").strip())
if not text:
return "本地捕获结果"
return text[:32].strip() or "本地捕获结果"
def _build_conclusion(evidence, text: str) -> str:
source_kind = str(getattr(evidence, "source_kind", "") or "").strip()
if source_kind == "pasted_text":
return "已基于输入文字生成本地可读摘要。"
if source_kind == "image":
return "已基于图片提取结果生成本地可读摘要。"
if source_kind == "video_url":
return "已基于视频证据生成本地可读摘要。"
if text:
return "已基于当前证据生成本地可读摘要。"
return "已收到输入,但证据内容仍然较少。"
FILE:scripts/runtime/openclaw_capture_skill/notifiers.py
"""Notification fanout and shared envelope rendering."""
from __future__ import annotations
from types import SimpleNamespace
import json
from pathlib import Path
from typing import Any, Callable
from urllib import parse as urlparse
from urllib import request as urlrequest
from .compat import ensure_legacy_import_path
def _post_urlencoded(url: str, payload: dict[str, Any]) -> None:
data = urlparse.urlencode(payload).encode("utf-8")
req = urlrequest.Request(url, data=data, method="POST")
with urlrequest.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read().decode("utf-8"))
if isinstance(body, dict) and body.get("ok") is False:
raise RuntimeError(f"request failed: {body}")
def _post_json(url: str, payload: dict[str, Any]) -> None:
req = urlrequest.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlrequest.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8").strip()
if raw:
try:
body = json.loads(raw)
except json.JSONDecodeError:
return
if isinstance(body, dict) and body.get("code") not in (None, 0):
raise RuntimeError(f"request failed: {body}")
def _summary_namespace(data: dict[str, Any]) -> SimpleNamespace:
payload = {
"title": data.get("title", ""),
"primary_topic": data.get("primary_topic", ""),
"secondary_topics": list(data.get("secondary_topics", [])),
"entities": list(data.get("entities", [])),
"conclusion": data.get("conclusion", ""),
"bullets": list(data.get("bullets", [])),
"evidence_quotes": list(data.get("evidence_quotes", [])),
"coverage": data.get("coverage", "partial"),
"confidence": data.get("confidence", "medium"),
"note_tags": list(data.get("note_tags", [])),
"follow_up_actions": list(data.get("follow_up_actions", [])),
"timeliness": data.get("timeliness", "medium"),
"effectiveness": data.get("effectiveness", "medium"),
"recommendation_level": data.get("recommendation_level", "optional"),
"reader_judgment": data.get("reader_judgment", ""),
}
return SimpleNamespace(**payload)
def _ingest_namespace(data: dict[str, Any]) -> SimpleNamespace:
payload = {
"chat_id": str(data.get("chat_id", "")),
"reply_to_message_id": data.get("reply_to_message_id"),
"request_id": data.get("request_id", ""),
"source_kind": data.get("source_kind", ""),
"source_url": data.get("source_url"),
"raw_text": data.get("raw_text"),
"image_refs": list(data.get("image_refs", [])),
"platform_hint": data.get("platform_hint"),
"requested_output_lang": data.get("requested_output_lang", "zh-CN"),
}
return SimpleNamespace(**payload)
def _evidence_namespace(data: dict[str, Any]) -> SimpleNamespace:
payload = {
"source_kind": data.get("source_kind", ""),
"source_url": data.get("source_url"),
"platform_hint": data.get("platform_hint"),
"title": data.get("title"),
"text": data.get("text", ""),
"evidence_type": data.get("evidence_type", ""),
"coverage": data.get("coverage", "partial"),
"transcript": data.get("transcript"),
"keyframes": list(data.get("keyframes", [])),
"metadata": dict(data.get("metadata", {})),
}
return SimpleNamespace(**payload)
class NullNotifier:
def send_result(self, *args, **kwargs) -> None:
return None
class EnvelopeRenderer:
def __init__(self, legacy_project_root: Path | None) -> None:
ensure_legacy_import_path(legacy_project_root)
from openclaw_capture_workflow.telegram import TelegramNotifier
self._builder = TelegramNotifier("disabled")
def render_text(
self,
ingest,
summary,
note_path: str,
structure_map: str,
open_url: str,
evidence=None,
summary_model: str | None = None,
summary_elapsed_seconds: float | None = None,
) -> str:
payload = self._builder.build_result_message_payload(
ingest,
summary,
note_path,
structure_map,
open_url,
evidence,
summary_model,
summary_elapsed_seconds,
)
return str(payload["text"])
class FanoutNotifier:
def __init__(
self,
*,
outputs: tuple[str, ...],
telegram_bot_token: str = "",
feishu_webhook: str = "",
legacy_project_root: Path | None = None,
text_renderer: Callable[..., str] | None = None,
telegram_sender: Callable[[dict[str, Any]], None] | None = None,
feishu_sender: Callable[[str], None] | None = None,
) -> None:
self.outputs = outputs
self.telegram_bot_token = telegram_bot_token
self.feishu_webhook = feishu_webhook
self._renderer = text_renderer or EnvelopeRenderer(legacy_project_root).render_text
self._telegram_sender = telegram_sender or self._send_telegram_payload
self._feishu_sender = feishu_sender or self._send_feishu_text
def _send_telegram_payload(self, payload: dict[str, Any]) -> None:
if not self.telegram_bot_token:
raise RuntimeError("telegram output selected but OPENCLAW_CAPTURE_TELEGRAM_BOT_TOKEN is missing")
_post_urlencoded(
f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage",
payload,
)
def _send_feishu_text(self, text: str) -> None:
if not self.feishu_webhook:
raise RuntimeError("feishu output selected but OPENCLAW_CAPTURE_FEISHU_WEBHOOK is missing")
_post_json(
self.feishu_webhook,
{
"msg_type": "text",
"content": {
"text": text,
},
},
)
def _fanout(
self,
ingest,
summary,
note_path: str,
structure_map: str,
open_url: str,
evidence=None,
summary_model: str | None = None,
summary_elapsed_seconds: float | None = None,
skip_outputs: set[str] | None = None,
) -> str:
skip_outputs = skip_outputs or set()
text = self._renderer(
ingest,
summary,
note_path,
structure_map,
open_url,
evidence,
summary_model,
summary_elapsed_seconds,
)
if "telegram" in self.outputs and "telegram" not in skip_outputs:
payload: dict[str, Any] = {
"chat_id": str(getattr(ingest, "chat_id", "")),
"text": text,
}
reply_to_message_id = getattr(ingest, "reply_to_message_id", None)
if reply_to_message_id not in (None, ""):
payload["reply_to_message_id"] = str(reply_to_message_id)
self._telegram_sender(payload)
if "feishu" in self.outputs and "feishu" not in skip_outputs:
self._feishu_sender(text)
return text
def send_result(
self,
ingest,
summary,
note_path: str,
structure_map: str,
open_url: str,
evidence=None,
summary_model: str | None = None,
summary_elapsed_seconds: float | None = None,
) -> None:
self._fanout(
ingest,
summary,
note_path,
structure_map,
open_url,
evidence,
summary_model,
summary_elapsed_seconds,
)
def send_from_job_result(
self,
ingest_payload: dict[str, Any],
job: dict[str, Any],
*,
skip_outputs: set[str] | None = None,
) -> str:
result = dict(job.get("result", {}))
note = dict(result.get("note", {}))
summary = _summary_namespace(dict(result.get("summary", {})))
evidence = _evidence_namespace(dict(result.get("evidence", {})))
ingest = _ingest_namespace(ingest_payload)
note_path = str(note.get("note_path", ""))
structure_map = str(note.get("structure_map", ""))
open_url = str(note.get("obsidian_uri") or result.get("open_url") or "")
return self._fanout(
ingest,
summary,
note_path,
structure_map,
open_url,
evidence,
result.get("summary_model"),
result.get("summary_elapsed_seconds"),
skip_outputs=skip_outputs,
)
FILE:scripts/runtime/openclaw_capture_skill/profiles.py
"""Routing helpers for STT, model providers, and output modules."""
from __future__ import annotations
import platform
VALID_STT_PROFILES = {"mac_local_first", "local_cli_then_remote", "remote_only"}
VALID_MODEL_PROFILES = {"openai_direct", "aihubmix_gateway"}
VALID_OUTPUTS = {"telegram", "feishu"}
def resolve_stt_profile(raw_value: str, *, local_command: str = "", system_name: str | None = None) -> str:
normalized = str(raw_value or "").strip().lower()
if normalized in VALID_STT_PROFILES:
return normalized
actual_system = (system_name or platform.system()).strip()
if actual_system == "Darwin":
return "mac_local_first"
if str(local_command or "").strip():
return "local_cli_then_remote"
return "remote_only"
def resolve_model_profile(raw_value: str) -> str:
normalized = str(raw_value or "").strip().lower()
if normalized in VALID_MODEL_PROFILES:
return normalized
return "aihubmix_gateway"
def parse_outputs(raw_value: str) -> tuple[str, ...]:
values = []
seen = set()
for item in str(raw_value or "").split(","):
normalized = item.strip().lower()
if not normalized or normalized not in VALID_OUTPUTS or normalized in seen:
continue
seen.add(normalized)
values.append(normalized)
return tuple(values)
FILE:scripts/runtime/openclaw_capture_skill/video_audio_bridge.py
"""STT bridge that picks local mac, local CLI, or remote legacy audio transcription."""
from __future__ import annotations
import argparse
import platform
from pathlib import Path
import shlex
import subprocess
import sys
from .compat import legacy_scripts_dir, require_legacy_project_root
from .config import Settings
def _run(args: list[str]) -> str:
try:
result = subprocess.run(args, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or "").strip()
stdout = (exc.stdout or "").strip()
msg = stderr or stdout or str(exc)
raise RuntimeError(msg) from exc
return (result.stdout or "").strip()
def _run_template(command: str, **kwargs: str) -> str:
rendered = command.format(**kwargs)
args = shlex.split(rendered)
return _run(args)
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--url", required=True)
parser.add_argument("--max-seconds", default="0")
parser.add_argument("--api-key", default="")
parser.add_argument("--api-base-url", default="")
return parser.parse_args(argv)
def _legacy_audio_script(legacy_project_root: Path) -> Path:
scripts_dir = legacy_scripts_dir(legacy_project_root)
return scripts_dir / "video_audio_asr.py"
def _call_legacy_audio(
legacy_project_root: Path,
*,
backend: str,
url: str,
max_seconds: str,
api_key: str,
api_base_url: str,
) -> str:
script_path = _legacy_audio_script(legacy_project_root)
return _run(
[
sys.executable,
str(script_path),
"--url",
url,
"--max-seconds",
str(max_seconds),
"--api-key",
api_key,
"--api-base-url",
api_base_url,
"--backend",
backend,
]
)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)
settings = Settings.from_env()
legacy_root = require_legacy_project_root(settings.legacy_project_root)
if settings.stt_profile == "mac_local_first" and platform.system() == "Darwin":
print(
_call_legacy_audio(
legacy_root,
backend="auto",
url=args.url,
max_seconds=str(args.max_seconds),
api_key=args.api_key,
api_base_url=args.api_base_url,
)
)
return 0
if settings.stt_profile == "local_cli_then_remote" and settings.local_stt_command.strip():
try:
local_output = _run_template(
settings.local_stt_command,
url=args.url,
max_seconds=str(args.max_seconds),
api_key=args.api_key,
api_base_url=args.api_base_url,
)
if local_output.strip():
print(local_output)
return 0
except Exception:
pass
print(
_call_legacy_audio(
legacy_root,
backend="remote",
url=args.url,
max_seconds=str(args.max_seconds),
api_key=args.api_key,
api_base_url=args.api_base_url,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/video_audio_bridge.py
#!/usr/bin/env python3
from pathlib import Path
import sys
RUNTIME_DIR = Path(__file__).resolve().parent / "runtime"
if str(RUNTIME_DIR) not in sys.path:
sys.path.insert(0, str(RUNTIME_DIR))
from openclaw_capture_skill.video_audio_bridge import main
if __name__ == "__main__":
raise SystemExit(main())