增加 khhao 平台
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
// @ts-check
|
||||
|
||||
import { recognizeImageCaptcha } from '../../session/ocr.js'
|
||||
import { buildKhhaoUrl, buildCookieHeader, normalizeKhhaoCookieState, resolveKhhaoConfig } from './shared.js'
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* baseUrl?: string
|
||||
* captchaPath?: string
|
||||
* timeoutMs?: number
|
||||
* cookies?: Record<string, string>
|
||||
* requestId?: string
|
||||
* includeImageBase64?: boolean
|
||||
* }} [options]
|
||||
*/
|
||||
export async function fetchKhhaoCaptcha(options = {}) {
|
||||
const config = resolveKhhaoConfig(options)
|
||||
const cookieHeader = buildCookieHeader(options.cookies)
|
||||
const response = await fetchWithTimeout(buildKhhaoUrl(config.baseUrl, config.captchaPath), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
referer: buildKhhaoUrl(config.baseUrl, config.loginPath).toString(),
|
||||
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
||||
},
|
||||
}, config.timeoutMs)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`khhao 验证码请求失败,HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.from(await response.arrayBuffer())
|
||||
const imageBase64 = imageBuffer.toString('base64')
|
||||
const ocr = await recognizeImageCaptcha({
|
||||
imageBase64,
|
||||
imageContentType: String(response.headers.get('content-type') || 'image/png').trim() || 'image/png',
|
||||
tag: options.requestId ? `khhao-${options.requestId}` : 'khhao-login-captcha',
|
||||
})
|
||||
|
||||
if (ocr?.code !== 0) {
|
||||
throw new Error(ocr?.msg || 'khhao 验证码 OCR 识别失败')
|
||||
}
|
||||
|
||||
const captchaText = String(ocr?.data?.text || ocr?.data?.recognizedText || '').trim()
|
||||
if (!captchaText) {
|
||||
throw new Error('khhao 验证码 OCR 未识别出内容')
|
||||
}
|
||||
|
||||
return {
|
||||
captchaText,
|
||||
ocr,
|
||||
cookieMap: normalizeKhhaoCookieState(options.cookies, response.headers),
|
||||
contentType: String(response.headers.get('content-type') || 'image/png').trim() || 'image/png',
|
||||
imageBase64: options.includeImageBase64 ? imageBase64 : '',
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input, init, timeoutMs) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
return await fetch(input, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`khhao 验证码请求超时(${timeoutMs}ms)`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// @ts-check
|
||||
|
||||
import { parseAmountToFen } from '../../../utils/money.js'
|
||||
|
||||
export function mapKhhaoOrderPreviewList(items = []) {
|
||||
return (Array.isArray(items) ? items : []).map((item) => mapKhhaoOrderPreview(item))
|
||||
}
|
||||
|
||||
export function mapKhhaoOrderPreview(item = {}) {
|
||||
const raw = isPlainObject(item) ? item : {}
|
||||
const platform = resolveKhhaoPlatform(raw.pingtai)
|
||||
const quantity = normalizeQuantity(raw.num)
|
||||
|
||||
return {
|
||||
provider: 'khhao',
|
||||
platform,
|
||||
platformLabel: String(raw.pingtaiName || '').trim(),
|
||||
platformOrderId: String(raw.ordersn || '').trim(),
|
||||
shopId: String(raw.shopid || '').trim(),
|
||||
shopName: String(raw.shopName || '').trim(),
|
||||
itemId: String(raw.goodid || '').trim(),
|
||||
itemTitle: String(raw.goodName || '').trim(),
|
||||
skuCode: String(raw.sku || '').trim(),
|
||||
quantity,
|
||||
totalAmountFen: parseAmountToFen(raw.fee),
|
||||
status: String(raw.status || '').trim(),
|
||||
statusLabel: stripHtmlTags(raw.statusName),
|
||||
orderCreatedAt: String(raw.addtime || '').trim(),
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveKhhaoPlatform(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
if (normalized === '3') {
|
||||
return 'kuaishou'
|
||||
}
|
||||
|
||||
return normalized ? 'unknown' : ''
|
||||
}
|
||||
|
||||
function normalizeQuantity(value) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1
|
||||
}
|
||||
|
||||
function stripHtmlTags(value) {
|
||||
return String(value || '').replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// @ts-check
|
||||
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { buildKhhaoUrl, resolveKhhaoConfig } from './shared.js'
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* session?: {
|
||||
* baseUrl?: string
|
||||
* cookieHeader?: string
|
||||
* }
|
||||
* page?: number | string
|
||||
* limit?: number | string
|
||||
* baseUrl?: string
|
||||
* orderListPath?: string
|
||||
* timeoutMs?: number
|
||||
* }} [payload]
|
||||
*/
|
||||
export async function queryKhhaoOrderList(payload = {}) {
|
||||
const config = resolveKhhaoConfig(payload)
|
||||
const page = normalizePage(payload.page)
|
||||
const limit = normalizeLimit(payload.limit)
|
||||
const session = payload.session || {}
|
||||
const cookieHeader = String(session.cookieHeader || '').trim()
|
||||
|
||||
if (!cookieHeader) {
|
||||
throw createHttpError('khhao 查询订单缺少登录态 Cookie', {
|
||||
statusCode: 400,
|
||||
errorCode: 'khhao_query_missing_cookie',
|
||||
})
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(buildKhhaoUrl(config.baseUrl, config.orderListPath, { page, limit }), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'application/json, text/javascript, */*; q=0.01',
|
||||
referer: buildKhhaoUrl(config.baseUrl, config.loginPath).toString(),
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
cookie: cookieHeader,
|
||||
},
|
||||
}, config.timeoutMs)
|
||||
|
||||
const rawText = await response.text()
|
||||
if (!response.ok) {
|
||||
throw createHttpError(`khhao 订单查询失败,HTTP ${response.status}`, {
|
||||
statusCode: 502,
|
||||
errorCode: 'khhao_query_http_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const payloadJson = tryParseJson(rawText)
|
||||
if (!payloadJson || typeof payloadJson !== 'object') {
|
||||
throw createHttpError('khhao 订单查询返回了无法解析的 JSON', {
|
||||
statusCode: 502,
|
||||
errorCode: 'khhao_query_invalid_json',
|
||||
})
|
||||
}
|
||||
|
||||
const items = Array.isArray(payloadJson.data) ? payloadJson.data : []
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
total: Number(payloadJson.count || items.length || 0),
|
||||
items,
|
||||
raw: payloadJson,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePage(value) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1
|
||||
}
|
||||
|
||||
function normalizeLimit(value) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return 50
|
||||
}
|
||||
|
||||
return Math.min(parsed, 200)
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input, init, timeoutMs) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
return await fetch(input, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw createHttpError(`khhao 订单查询超时(${timeoutMs}ms)`, {
|
||||
statusCode: 504,
|
||||
errorCode: 'khhao_query_timeout',
|
||||
})
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseJson(text) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// @ts-check
|
||||
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { logInfo, logWarn } from '../../../utils/logger.js'
|
||||
import { fetchKhhaoCaptcha } from './captcha-service.js'
|
||||
import {
|
||||
buildCookieHeader,
|
||||
buildKhhaoUrl,
|
||||
normalizeKhhaoCookieState,
|
||||
resolveKhhaoConfig,
|
||||
} from './shared.js'
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* username?: string
|
||||
* password?: string
|
||||
* baseUrl?: string
|
||||
* timeoutMs?: number
|
||||
* maxCaptchaAttempts?: number | string
|
||||
* includeImageBase64?: boolean
|
||||
* requestId?: string
|
||||
* }} [payload]
|
||||
*/
|
||||
export async function loginKhhaoSession(payload = {}) {
|
||||
const username = String(payload.username || '').trim()
|
||||
const password = String(payload.password || '').trim()
|
||||
const requestId = String(payload.requestId || '').trim()
|
||||
|
||||
if (!username) {
|
||||
throw createHttpError('khhao 登录缺少账号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'khhao_login_missing_username',
|
||||
})
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
throw createHttpError('khhao 登录缺少密码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'khhao_login_missing_password',
|
||||
})
|
||||
}
|
||||
|
||||
const config = resolveKhhaoConfig(payload)
|
||||
const maxCaptchaAttempts = normalizeAttempts(payload.maxCaptchaAttempts)
|
||||
/** @type {Record<string, string>} */
|
||||
let cookieMap = {}
|
||||
let lastFailureMessage = ''
|
||||
|
||||
cookieMap = await openKhhaoLoginPage(config, cookieMap)
|
||||
|
||||
for (let attempt = 1; attempt <= maxCaptchaAttempts; attempt += 1) {
|
||||
const captcha = await fetchKhhaoCaptcha({
|
||||
...config,
|
||||
cookies: cookieMap,
|
||||
requestId: requestId ? `${requestId}-captcha-${attempt}` : `login-${attempt}`,
|
||||
includeImageBase64: Boolean(payload.includeImageBase64),
|
||||
})
|
||||
cookieMap = captcha.cookieMap
|
||||
|
||||
const result = await submitKhhaoLogin({
|
||||
config,
|
||||
cookieMap,
|
||||
username,
|
||||
password,
|
||||
captchaText: normalizeKhhaoCaptchaText(captcha.captchaText),
|
||||
requestId,
|
||||
})
|
||||
|
||||
cookieMap = result.cookieMap
|
||||
|
||||
if (result.success) {
|
||||
logInfo('[khhao/session]', 'khhao 登录成功', {
|
||||
requestId,
|
||||
username,
|
||||
attempt,
|
||||
cookieKeys: Object.keys(cookieMap),
|
||||
})
|
||||
|
||||
return {
|
||||
baseUrl: config.baseUrl,
|
||||
cookieMap,
|
||||
cookieHeader: buildCookieHeader(cookieMap),
|
||||
loggedInAt: new Date().toISOString(),
|
||||
username,
|
||||
attempt,
|
||||
captchaText: captcha.captchaText,
|
||||
captchaImageBase64: captcha.imageBase64,
|
||||
responseMessage: result.message,
|
||||
}
|
||||
}
|
||||
|
||||
lastFailureMessage = result.message
|
||||
logWarn('[khhao/session]', 'khhao 登录失败', {
|
||||
requestId,
|
||||
username,
|
||||
attempt,
|
||||
message: result.message,
|
||||
})
|
||||
|
||||
if (!shouldRetryLogin(result.message) || attempt >= maxCaptchaAttempts) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
throw createHttpError(lastFailureMessage || 'khhao 登录失败', {
|
||||
statusCode: 401,
|
||||
errorCode: 'khhao_login_failed',
|
||||
})
|
||||
}
|
||||
|
||||
async function openKhhaoLoginPage(config, currentCookies) {
|
||||
const response = await fetchWithTimeout(buildKhhaoUrl(config.baseUrl, config.loginPath), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
}, config.timeoutMs)
|
||||
|
||||
if (!response.ok) {
|
||||
throw createHttpError(`khhao 登录页打开失败,HTTP ${response.status}`, {
|
||||
statusCode: 502,
|
||||
errorCode: 'khhao_login_page_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return normalizeKhhaoCookieState(currentCookies, response.headers)
|
||||
}
|
||||
|
||||
async function submitKhhaoLogin({ config, cookieMap, username, password, captchaText, requestId }) {
|
||||
const response = await fetchWithTimeout(buildKhhaoUrl(config.baseUrl, config.loginPath), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json, text/javascript, */*; q=0.01',
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
origin: config.baseUrl,
|
||||
referer: buildKhhaoUrl(config.baseUrl, config.loginPath).toString(),
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
...(buildCookieHeader(cookieMap) ? { cookie: buildCookieHeader(cookieMap) } : {}),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
username,
|
||||
password,
|
||||
img_code: captchaText,
|
||||
}).toString(),
|
||||
}, config.timeoutMs)
|
||||
|
||||
const rawText = await response.text()
|
||||
const cookieState = normalizeKhhaoCookieState(cookieMap, response.headers)
|
||||
|
||||
if (!response.ok) {
|
||||
throw createHttpError(`khhao 登录请求失败,HTTP ${response.status}`, {
|
||||
statusCode: 502,
|
||||
errorCode: 'khhao_login_request_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const payload = tryParseJson(rawText)
|
||||
const errcode = Number(payload?.errcode ?? 1)
|
||||
const message = String(payload?.msg || '').trim() || 'khhao 登录失败'
|
||||
|
||||
return {
|
||||
success: errcode === 0,
|
||||
message,
|
||||
payload,
|
||||
cookieMap: cookieState,
|
||||
requestId,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetryLogin(message) {
|
||||
const normalized = String(message || '').trim()
|
||||
return normalized.includes('验证码')
|
||||
}
|
||||
|
||||
function normalizeAttempts(value) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return 3
|
||||
}
|
||||
|
||||
return Math.min(parsed, 5)
|
||||
}
|
||||
|
||||
function normalizeKhhaoCaptchaText(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input, init, timeoutMs) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
return await fetch(input, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw createHttpError(`khhao 请求超时(${timeoutMs}ms)`, {
|
||||
statusCode: 504,
|
||||
errorCode: 'khhao_request_timeout',
|
||||
})
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseJson(text) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// @ts-check
|
||||
|
||||
import { runtimeConfig } from '../../../config/runtime.js'
|
||||
|
||||
/** @typedef {import('../../../types/runtime-config.js').RuntimeConfig} RuntimeConfig */
|
||||
|
||||
export function resolveKhhaoConfig(overrides = {}) {
|
||||
/** @type {RuntimeConfig['platforms']['khhao']} */
|
||||
const baseConfig = runtimeConfig.platforms?.khhao || {
|
||||
baseUrl: '',
|
||||
timeoutMs: 5000,
|
||||
loginPath: '/c/login/index.php',
|
||||
captchaPath: '/verify_img.php',
|
||||
orderListPath: '/c/payOrder/get.php',
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: normalizeBaseUrl(overrides.baseUrl || baseConfig.baseUrl),
|
||||
timeoutMs: normalizePositiveInteger(overrides.timeoutMs || baseConfig.timeoutMs, 5000),
|
||||
loginPath: normalizePath(overrides.loginPath || baseConfig.loginPath, '/c/login/index.php'),
|
||||
captchaPath: normalizePath(overrides.captchaPath || baseConfig.captchaPath, '/verify_img.php'),
|
||||
orderListPath: normalizePath(overrides.orderListPath || baseConfig.orderListPath, '/c/payOrder/get.php'),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildKhhaoUrl(baseUrl, pathname, searchParams = null) {
|
||||
const url = new URL(normalizePath(pathname, '/'), normalizeBaseUrl(baseUrl) || 'https://admin.khhao.com')
|
||||
|
||||
if (searchParams && typeof searchParams === 'object') {
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
if (typeof value === 'undefined' || value === null || value === '') {
|
||||
continue
|
||||
}
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export function collectSetCookieHeaders(headers) {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
if (typeof headers.getSetCookie === 'function') {
|
||||
return headers.getSetCookie()
|
||||
}
|
||||
|
||||
const raw = headers.get('set-cookie')
|
||||
return raw ? [raw] : []
|
||||
}
|
||||
|
||||
export function mergeCookies(currentCookies = {}, setCookieHeaders = []) {
|
||||
const next = { ...currentCookies }
|
||||
|
||||
for (const header of Array.isArray(setCookieHeaders) ? setCookieHeaders : []) {
|
||||
const text = String(header || '').trim()
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pair = text.split(';', 1)[0]
|
||||
const separatorIndex = pair.indexOf('=')
|
||||
if (separatorIndex <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = pair.slice(0, separatorIndex).trim()
|
||||
const value = pair.slice(separatorIndex + 1).trim()
|
||||
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (value) {
|
||||
next[key] = value
|
||||
} else {
|
||||
delete next[key]
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function buildCookieHeader(cookieMap = {}) {
|
||||
return Object.entries(cookieMap)
|
||||
.filter(([key, value]) => String(key || '').trim() && String(value || '').trim())
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
export function normalizeKhhaoCookieState(currentCookies = {}, headers) {
|
||||
return mergeCookies(currentCookies, collectSetCookieHeaders(headers))
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function normalizePath(value, fallback) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return normalized.startsWith('/') ? normalized : `/${normalized}`
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value, fallback) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
Reference in New Issue
Block a user