init: video-create project with skills and accounts

This commit is contained in:
2026-04-29 21:04:43 +08:00
commit dadddc7aec
64 changed files with 14715 additions and 0 deletions

View File

@@ -0,0 +1,680 @@
/**
* Kling AI HTTP client (zero external deps, Node.js 18+ fetch)
*
* - **klingGet / klingPost**Bearer 鉴权 + resolveApiBase业务 API
* - **runAccountBindHttpSequence**:无 Bearer固定 bind 端点(与鉴权流量区分在实现上,不混用 token
*/
import { createHash, randomBytes } from 'node:crypto';
import {
getBearerToken,
makeKlingHeaders,
getConfiguredApiBase,
getConfiguredBindBase,
persistProbedApiBase,
getSkillVersion,
ensureIdentityForBind,
patchKlingIdentity,
persistBoundApiKeys,
} from './auth.mjs';
const KLING_API_ENDPOINTS = Object.freeze([
{
key: 'cn',
apiBase: 'https://api-beijing.klingai.com',
bindBase: 'https://klingai.com',
consoleUrl: 'https://klingai.com/dev/api-key',
},
{
key: 'global',
apiBase: 'https://api-singapore.klingai.com',
bindBase: 'https://kling.ai',
consoleUrl: 'https://kling.ai/dev/api-key',
},
]);
const ALL_KLING_CONSOLE_URLS = Object.freeze(
Object.fromEntries(KLING_API_ENDPOINTS.map((item) => [item.key, item.consoleUrl])),
);
const API_BASE = KLING_API_ENDPOINTS[0].apiBase;
const CANDIDATE_BASES = KLING_API_ENDPOINTS.map((item) => item.apiBase);
export let KLING_CONSOLE_URLS = ALL_KLING_CONSOLE_URLS;
function normalizeApiBase(base) {
return String(base || '').trim().replace(/\/+$/, '');
}
function findEndpointByBase(base) {
const normalized = normalizeApiBase(base);
if (!normalized) return null;
const direct = KLING_API_ENDPOINTS.find((item) => normalizeApiBase(item.apiBase) === normalized);
if (direct) return direct;
const bindDirect = KLING_API_ENDPOINTS.find((item) => normalizeApiBase(item.bindBase) === normalized);
if (bindDirect) return bindDirect;
if (normalized.includes('api-beijing.klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
if (normalized.includes('api-singapore.klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'global') || null;
if (normalized.includes('klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
if (normalized.includes('kling.ai')) return KLING_API_ENDPOINTS.find((item) => item.key === 'global') || null;
if (normalized.includes('kuaishou.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
return null;
}
function setConsoleUrlsForBase(base) {
const endpoint = findEndpointByBase(base);
if (!endpoint) {
KLING_CONSOLE_URLS = ALL_KLING_CONSOLE_URLS;
return;
}
KLING_CONSOLE_URLS = Object.freeze({ [endpoint.key]: endpoint.consoleUrl });
}
const initialConfiguredApiBase = getConfiguredApiBase();
if (initialConfiguredApiBase) {
setConsoleUrlsForBase(initialConfiguredApiBase);
}
function printConsoleUrlsHint(prefix = ' ') {
for (const [region, url] of Object.entries(KLING_CONSOLE_URLS)) {
const label = region === 'cn' ? 'China / 国内' : (region === 'global' ? 'Global / 国际' : region);
console.error(`${prefix}${label}: ${url}`);
}
}
async function probeBase(base, token) {
try {
const res = await fetch(`${base}/v1/videos/text2video?pageNum=1&pageSize=1`, {
method: 'GET',
headers: makeKlingHeaders(token, null),
signal: AbortSignal.timeout(8000),
});
if (!res.ok) return false;
const json = await res.json().catch(() => null);
return json != null && (json.code === 0 || json.code === 200);
} catch {
return false;
}
}
let _resolvedBase = null;
async function resolveApiBase(token) {
if (_resolvedBase) return _resolvedBase;
const configuredApiBase = getConfiguredApiBase();
if (configuredApiBase) {
_resolvedBase = normalizeApiBase(configuredApiBase);
setConsoleUrlsForBase(_resolvedBase);
return _resolvedBase;
}
console.error('\n🔍 Probing API endpoints... / 正在检测 API 节点...');
for (const endpoint of KLING_API_ENDPOINTS) {
process.stderr.write(` [${endpoint.key}] ${endpoint.apiBase} ... `);
if (await probeBase(endpoint.apiBase, token)) {
process.stderr.write('✓ OK\n\n');
_resolvedBase = endpoint.apiBase;
setConsoleUrlsForBase(_resolvedBase);
try {
persistProbedApiBase(_resolvedBase);
} catch {}
return _resolvedBase;
}
process.stderr.write('✗\n');
}
console.error('\n❌ Cannot connect to any Kling API endpoint / 无法连接任何可灵 API 节点');
for (const base of CANDIDATE_BASES) console.error(`${base}`);
console.error('\nPossible causes / 可能原因:');
console.error(' 1. Token invalid or expired / Token 无效或已过期:');
printConsoleUrlsHint();
console.error(' 2. Network issue / 网络问题');
console.error('\nCheck credentials file, KLING_TOKEN, or run account configure / 检查 credentials、KLING_TOKEN 或 account configure:\n');
process.exit(1);
}
/**
* 保护 JSON 中的大整数字段(防止 Number 精度丢失)
* 将 element_id, task_id 等大整数字段转为字符串
*/
function protectBigInts(text) {
return text.replace(
/"(element_id|task_id|elementId|taskId)":\s*(\d{15,})/g,
'"$1":"$2"'
);
}
/**
* 解析可灵 API 响应code 为 0 或 200 为成功
*/
function parseResponse(json) {
if (json.code !== 0 && json.code !== 200) {
throw new Error(`API error / API 错误 (code=${json.code}): ${json.message || 'Unknown error'}`);
}
return json.data;
}
function parseJsonSafely(text) {
try {
return JSON.parse(protectBigInts(String(text || '')));
} catch {
return null;
}
}
function buildHttpErrorMessage(status, text) {
const body = parseJsonSafely(text);
if (status === 401 && body && typeof body === 'object') {
const code = Number(body.code);
const requestId = body.request_id ? `, request_id=${body.request_id}` : '';
if (code === 1000) {
return `HTTP 401: code=1000signature is invalid / 秘钥无效,请重新绑定${requestId}`;
}
if (code === 1002) {
return `HTTP 401: code=1002access key not exist / 账户不存在,请重新绑定${requestId}`;
}
}
return `HTTP ${status}: ${text}`;
}
function parseApiJsonOrThrow(text) {
const parsed = parseJsonSafely(text);
if (parsed != null) return parsed;
const preview = String(text || '').trim().slice(0, 60);
if (preview.startsWith('<')) {
throw new Error(`API Service Error: Non-JSON content. check KLING_API_BASE and network/DNS/proxy: ${preview}`);
}
throw new Error(`API Service Error: Cannot parse JSON: ${preview}`);
}
async function safeFetch(url, init, context) {
try {
return await fetch(url, init);
} catch (e) {
const baseHint = getConfiguredApiBase() || '<auto>';
const msg = e?.message || String(e);
throw new Error(
`Network error / 网络错误: ${msg}\n`
+ `Request / 请求: ${context.method} ${url}\n`
+ `KLING_API_BASE: ${baseHint}\n`
+ 'Hint / 提示: check KLING_API_BASE and network/DNS/proxy, or remove KLING_API_BASE to auto-probe official endpoints / '
+ '请检查 KLING_API_BASE 与网络(DNS/代理),或移除 KLING_API_BASE 让脚本自动探测官方节点。',
);
}
}
/**
* POST 请求可灵 API
* @param {string} path API 路径,如 /v1/videos/image2video
* @param {object} body 请求体
* @param {string} [token] 可选 token不传则自动获取
* @returns {Promise<object>} data 字段
*/
export async function klingPost(path, body, token) {
if (!token) token = getBearerToken();
const base = await resolveApiBase(token);
const url = `${base}${path}`;
const res = await safeFetch(url, {
method: 'POST',
headers: makeKlingHeaders(token),
body: JSON.stringify(body),
}, { method: 'POST' });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(buildHttpErrorMessage(res.status, text));
}
const text = await res.text();
return parseResponse(parseApiJsonOrThrow(text));
}
/**
* GET 请求可灵 API
* @param {string} path API 路径,如 /v1/videos/image2video/{task_id}
* @param {string} [token] 可选 token不传则自动获取
* @param {{ contentType?: string|null }} [options] 如部分接口要求 `Content-Type: application/json`(传 `'application/json'`);默认不传 Content-Type
* @returns {Promise<object>} data 字段
*/
export async function klingGet(path, token, options = {}) {
if (!token) token = getBearerToken();
const base = await resolveApiBase(token);
const ct = options.contentType !== undefined ? options.contentType : null;
const url = `${base}${path}`;
const res = await safeFetch(url, {
method: 'GET',
headers: makeKlingHeaders(token, ct),
}, { method: 'GET' });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(buildHttpErrorMessage(res.status, text));
}
const text = await res.text();
return parseResponse(parseApiJsonOrThrow(text));
}
// —— 设备绑定 HTTP无 Authorization不经过 resolveApiBase ——
const DEFAULT_BIND_INIT = '/console/api/auth/skill/init-sessions';
const DEFAULT_BIND_EXCHANGE = '/console/api/auth/skill/exchange';
const DEFAULT_BIND_SKILL_ID = 'Kling-Provider-Skill';
const DEFAULT_BIND_SCOPE = 'kling.openapi.invoke';
const DEFAULT_BIND_FETCH_TIMEOUT_MS = 30000;
const DEFAULT_BIND_TIMEOUT_MS = 180000;
function sleepBind(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function base64url(input) {
return Buffer.from(input).toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function createPkcePair() {
const codeVerifier = base64url(randomBytes(48));
const codeChallenge = base64url(createHash('sha256').update(codeVerifier, "utf8").digest());
return { codeVerifier, codeChallenge };
}
function bindExtractData(json) {
if (json == null || typeof json !== 'object') return json;
const c = json.code;
if (c !== undefined && c !== 0 && c !== 200) {
const msg = json.message || json.msg || 'Unknown error';
throw new Error(`Bind API error / 绑定接口错误 (code=${c}): ${msg}`);
}
return json.data !== undefined ? json.data : json;
}
function normalizeBindBase(base) {
const raw = String(base || '').trim();
return raw.replace(/\/+$/, '');
}
function resolveBindBase(bindBaseOverride) {
const override = normalizeBindBase(bindBaseOverride);
if (override) {
const overrideEndpoint = findEndpointByBase(override);
if (overrideEndpoint?.bindBase) return normalizeBindBase(overrideEndpoint.bindBase);
return override;
}
const configuredBindBase = getConfiguredBindBase();
if (configuredBindBase) return normalizeBindBase(configuredBindBase);
const candidate = getConfiguredApiBase() || _resolvedBase || API_BASE;
const endpoint = findEndpointByBase(candidate);
if (endpoint?.bindBase) return normalizeBindBase(endpoint.bindBase);
return normalizeBindBase(candidate);
}
async function skillBindHttpJson(userAgent, base, path, body, method = 'POST') {
const b = String(base || '').replace(/\/$/, '');
const p = path.startsWith('/') ? path : `/${path}`;
const url = method === 'GET' && body && typeof body === 'object'
? `${b}${p}${p.includes('?') ? '&' : '?'}${new URLSearchParams(
Object.entries(body).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]),
).toString()}`
: `${b}${p}`;
const headers = { 'User-Agent': userAgent };
if (method !== 'GET') headers['Content-Type'] = 'application/json';
const init = {
method,
headers,
signal: AbortSignal.timeout(DEFAULT_BIND_FETCH_TIMEOUT_MS),
};
if (method !== 'GET' && body != null) init.body = JSON.stringify(body);
let res;
try {
res = await fetch(url, init);
} catch (e) {
throw new Error(
`Network error / 网络错误: ${e?.message || e}\n`
+ 'Hint / 提示: check network/DNS/proxy and endpoint reachability / 请检查网络、DNS、代理与目标地址可达性。',
);
}
const text = await res.text().catch(() => '');
if (!res.ok) {
throw new Error(
`HTTP ${res.status}: ${text}\n`
+ 'Hint / 提示: verify API base and network reachability / 请确认 API 基址与网络可达性。',
);
}
let json;
try {
json = JSON.parse(text);
} catch {
throw new Error(`Invalid JSON / 非 JSON 响应: ${text.slice(0, 200)}`);
}
return bindExtractData(json);
}
function pickBindSessionId(data) {
if (!data || typeof data !== 'object') return null;
return data.session_id || data.sessionId || data.bind_session_id || data.id || null;
}
function pickBindAuthorizeHint(data) {
if (!data || typeof data !== 'object') return null;
return (
data.verificationUriComplete
|| data.verification_uri_complete
|| data.verificationUri
|| data.verification_uri
|| data.authorize_url
|| data.authorization_url
|| data.qr_url
|| null
);
}
function pickBindAccessSecretKeys(data) {
const src = data?.credential && typeof data.credential === 'object' ? data.credential : data;
if (!src || typeof src !== 'object') {
return {
ak: null, sk: null, credentialId: null, accountId: null,
};
}
const ak = src.accessKey || src.access_key || src.access_key_id || src.accessKeyId || src.ak;
const sk = src.secretKey || src.secret_key || src.secret_access_key || src.secretAccessKey || src.sk;
const credentialId = src.credentialId || src.credential_id || src.credentialID || src.credentialid;
const accountId = src.accountId || src.account_id || src.accountID || src.accountid;
return {
ak: ak != null ? String(ak).trim() : null,
sk: sk != null ? String(sk).trim() : null,
credentialId: credentialId != null ? String(credentialId).trim() : null,
accountId: accountId != null ? String(accountId).trim() : null,
};
}
function normalizeBindStatus(data) {
if (!data || typeof data !== 'object') return 'pending';
const s = data.status || data.state || data.bind_status || data.phase;
if (s == null) return 'pending';
return String(s).toUpperCase();
}
function makeBindFlowError(message, meta = {}) {
const err = new Error(message);
err.name = 'BindFlowError';
if (meta.code) err.bindCode = meta.code;
if (meta.authorizeUrl) err.bindAuthorizeUrl = meta.authorizeUrl;
if (meta.sessionId) err.bindSessionId = meta.sessionId;
if (meta.status) err.bindStatus = meta.status;
if (meta.responseData !== undefined) err.bindResponseData = meta.responseData;
return err;
}
function resolveAuthorizationUrl(bindBase, authorizePathOrUrl) {
const raw = String(authorizePathOrUrl || '').trim();
if (!raw) return null;
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
const baseUrl = new URL(`${normalizeBindBase(bindBase)}/`);
if (raw.startsWith('/')) return `${baseUrl.origin}${raw}`;
return new URL(raw, baseUrl).toString();
}
function defaultBindOnLog(ev) {
if (ev.url) {
console.error(`${ev.message}\n ${ev.url}`);
} else {
console.error(ev.message);
}
}
function maskSecret(secret) {
const s = String(secret || '');
if (!s) return '';
if (s.length <= 6) return '***';
return `${s.slice(0, 3)}***${s.slice(-2)}`;
}
function maskAccessKey(accessKey) {
const s = String(accessKey || '');
if (!s) return '';
if (s.length <= 8) return `${s.slice(0, 2)}***`;
return `${s.slice(0, 4)}***${s.slice(-3)}`;
}
/**
* 执行完整设备绑定并写入 credentials供 account 与 getTokenOrExit 自动调用)
* @param {{ onLog?: function }} [options]
*/
export async function runDeviceBindFlow(options = {}) {
const onLog = options.onLog || defaultBindOnLog;
const identity = ensureIdentityForBind();
const {
client_instance_id, device_name, platform, hostname,
} = identity;
const userAgent = `Kling-Provider-Skill/${getSkillVersion()}`;
const result = await runAccountBindHttpSequence({
userAgent,
skillVersion: getSkillVersion(),
identity: {
clientInstanceId: client_instance_id,
deviceName: device_name,
platform,
hostname,
},
onInitSession: (sessionId) => {
patchKlingIdentity({ session_id: sessionId });
},
onLog,
});
const persisted = persistBoundApiKeys(
result.accessKey,
result.secretKey,
{ session_id: result.sessionId },
{
credentialId: result.credentialId || null,
accountId: result.accountId || null,
},
);
return {
sessionId: result.sessionId,
authorizeUrl: result.authorizeHint || null,
savePath: persisted.savePath,
accessKeyMasked: maskAccessKey(result.accessKey),
secretKeyMasked: maskSecret(result.secretKey),
};
}
/**
* 仅执行绑定前置init → verify返回可手动打开的授权 URL。
* @param {{ onLog?: function }} [options]
*/
export async function prepareDeviceBindUrl(options = {}) {
const onLog = options.onLog || defaultBindOnLog;
const identity = ensureIdentityForBind();
const {
client_instance_id, device_name, platform, hostname,
} = identity;
const userAgent = `Kling-Provider-Skill/${getSkillVersion()}`;
const result = await runAccountBindInitVerify({
userAgent,
skillVersion: getSkillVersion(),
identity: {
clientInstanceId: client_instance_id,
deviceName: device_name,
platform,
hostname,
},
onInitSession: (sessionId) => {
patchKlingIdentity({ session_id: sessionId });
},
onLog,
});
return {
sessionId: result.sessionId,
authorizeUrl: result.authorizeHint || null,
};
}
/**
* 账号绑定前半段init → verify拿到可给用户手动打开的授权 URL。
* @returns {Promise<{sessionId: string, authorizeHint: string|null}>}
*/
export async function runAccountBindInitVerify(options) {
const bindBase = options.bindBase ? normalizeBindBase(options.bindBase) : resolveBindBase();
const initPath = options.initPath || DEFAULT_BIND_INIT;
const {
clientInstanceId,
deviceName,
platform,
hostname,
} = options.identity || {};
if (!clientInstanceId) {
throw makeBindFlowError('identity.clientInstanceId is required / 缺少 identity.clientInstanceId', { code: 'MISSING_CLIENT_INSTANCE_ID' });
}
const userAgent = String(options.userAgent || 'Kling-Provider-Skill/unknown');
const skillVersion = String(options.skillVersion || getSkillVersion());
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
const onInitSession = options.onInitSession;
const { codeVerifier, codeChallenge } = createPkcePair();
onLog({ step: 'base', message: 'Using bind base / 当前 bind 基址:', url: bindBase });
onLog({ step: 'init', message: 'Calling init-sessions / 调用 init-sessions …' });
const initData = await skillBindHttpJson(userAgent, bindBase, initPath, {
skillId: DEFAULT_BIND_SKILL_ID,
skillVersion,
clientInstanceId,
deviceName: String(deviceName || '').trim() || 'unknown',
platform: String(platform || '').trim() || 'unknown',
hostname: String(hostname || '').trim() || 'unknown',
requestedScopes: [DEFAULT_BIND_SCOPE],
codeChallenge,
codeChallengeMethod: 'S256',
});
const sessionId = pickBindSessionId(initData);
if (!sessionId) {
throw makeBindFlowError(
'init-sessions response missing sessionId / init-sessions 响应缺少 sessionId',
{ code: 'MISSING_SESSION_ID' },
);
}
if (onInitSession) await onInitSession(sessionId);
const deviceCode = String(initData.deviceCode || initData.device_code || '').trim();
if (!deviceCode) {
throw makeBindFlowError(
'init-sessions response missing deviceCode / init-sessions 响应缺少 deviceCode',
{ code: 'MISSING_DEVICE_CODE', sessionId },
);
}
const authorizeHint = resolveAuthorizationUrl(bindBase, pickBindAuthorizeHint(initData));
if (!authorizeHint) {
throw makeBindFlowError(
'init-sessions response missing authorize url / init-sessions 响应缺少授权链接',
{ code: 'MISSING_AUTHORIZE_URL', sessionId },
);
}
onLog({ step: 'authorize', message: 'Open in browser / 请在浏览器完成授权:', url: authorizeHint });
return {
sessionId,
deviceCode,
codeVerifier,
authorizeHint,
interval: Number(initData.interval),
expiresIn: Number(initData.expiresIn),
};
}
/**
* 账号设备绑定init → verify → 轮询 check。无 Bearer凭证落盘由调用方配合 auth 负责。
*/
export async function runAccountBindHttpSequence(options) {
const bindBase = resolveBindBase(options.bindBase);
const exchangePath = options.exchangePath || DEFAULT_BIND_EXCHANGE;
const timeoutMs = Math.max(1000, Number(options.timeoutMs ?? DEFAULT_BIND_TIMEOUT_MS));
const userAgent = String(options.userAgent || 'Kling-Provider-Skill/unknown');
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
const {
sessionId,
deviceCode,
codeVerifier,
authorizeHint,
expiresIn,
} = await runAccountBindInitVerify({
...options,
bindBase,
userAgent,
onLog,
});
const deadline = Date.now() + timeoutMs;
// 服务端已返回 ttl优先取较小值避免本地等待过长。
let remainingTtlSec = Number.isFinite(Number(expiresIn))
? Number(expiresIn)
: null;
while (Date.now() < deadline) {
if (remainingTtlSec != null && remainingTtlSec <= 0) {
throw makeBindFlowError('Bind expired / 绑定已过期', {
code: 'BIND_EXPIRED',
authorizeUrl: authorizeHint,
sessionId,
status: 'EXPIRED',
});
}
onLog({ step: 'exchange', message: 'Polling exchange / 轮询 exchange …' });
const exchangeData = await skillBindHttpJson(userAgent, bindBase, exchangePath, {
sessionId,
deviceCode,
codeVerifier,
}, 'POST');
const status = normalizeBindStatus(exchangeData);
if (status === 'ISSUED' || status === 'ALREADY_EXCHANGED') {
const {
ak, sk, credentialId, accountId,
} = pickBindAccessSecretKeys(exchangeData);
if (!ak || !sk) {
throw makeBindFlowError(`${status} without credential / ${status} 但缺少 credential`, {
code: 'MISSING_CREDENTIAL',
authorizeUrl: authorizeHint,
sessionId,
status,
responseData: exchangeData,
});
}
return {
sessionId,
authorizeHint,
accessKey: ak,
secretKey: sk,
credentialId,
accountId,
status,
};
}
if (status !== 'PENDING') {
throw makeBindFlowError(`Bind status: ${status}`, {
code: 'BIND_STATUS',
authorizeUrl: authorizeHint,
sessionId,
status,
responseData: exchangeData,
});
}
const waitSec = Number(exchangeData?.pollAfterSeconds);
const nextExpiresSec = Number(exchangeData?.expiresIn);
if (Number.isFinite(nextExpiresSec)) remainingTtlSec = nextExpiresSec;
if (!Number.isFinite(waitSec) || waitSec <= 0) {
throw makeBindFlowError('Missing pollAfterSeconds, treat as timeout / 缺少 pollAfterSeconds按超时处理', {
code: 'BIND_TIMEOUT',
authorizeUrl: authorizeHint,
sessionId,
status,
});
}
await sleepBind(waitSec * 1000);
}
throw makeBindFlowError(`Bind timeout / 绑定超时(>${timeoutMs}ms`, {
code: 'BIND_TIMEOUT',
authorizeUrl: authorizeHint,
sessionId,
status: 'TIMEOUT',
});
}
export { getBearerToken, makeKlingHeaders, setSkillVersion, getSkillVersion } from './auth.mjs';
export { API_BASE, CANDIDATE_BASES, resolveApiBase };