feat: initial commit

This commit is contained in:
ivanberry 2026-03-12 07:35:25 +08:00
commit 1563c43a0b
27 changed files with 1786 additions and 0 deletions

View File

@ -0,0 +1,125 @@
name: register-skill-release
on:
release:
types: [published]
workflow_dispatch:
inputs:
skill_slug:
description: Skill slug override (optional)
required: false
skill_subpath:
description: Skill folder path override (optional)
required: false
skill_doc_path:
description: Skill doc path override
required: false
default: SKILL.md
skill_version:
description: Version override (default tag name)
required: false
jobs:
register-skill-version:
runs-on: ubuntu-latest
env:
API_BASE: ${{ vars.API_BASE || secrets.API_BASE }}
CLIENT_KEY: ${{ secrets.CLIENT_KEY }}
SKILL_VERSION: ${{ github.event.inputs.skill_version || github.ref_name }}
SKILL_SUBPATH: ${{ github.event.inputs.skill_subpath || vars.SKILL_SUBPATH || secrets.SKILL_SUBPATH }}
SKILL_DOC_PATH: ${{ github.event.inputs.skill_doc_path || vars.SKILL_DOC_PATH || secrets.SKILL_DOC_PATH || 'SKILL.md' }}
SKILL_SLUG: ${{ github.event.inputs.skill_slug || vars.SKILL_SLUG || secrets.SKILL_SLUG }}
RELEASE_NOTE: ${{ github.event.release.body }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Load skill doc content
shell: bash
run: |
set -euo pipefail
DOC_ABS_PATH="${SKILL_SUBPATH:+$SKILL_SUBPATH/}${SKILL_DOC_PATH}"
if [ ! -f "$DOC_ABS_PATH" ]; then
if [ -f "${SKILL_SUBPATH:+$SKILL_SUBPATH/}README.md" ]; then
DOC_ABS_PATH="${SKILL_SUBPATH:+$SKILL_SUBPATH/}README.md"
export SKILL_DOC_PATH="README.md"
else
echo "skill doc not found: $DOC_ABS_PATH"
exit 1
fi
fi
jq -Rs . < "$DOC_ABS_PATH" > /tmp/skill_doc.json
- name: Register version to business system
shell: bash
run: |
set -euo pipefail
if [ -z "${API_BASE:-}" ]; then
echo "API_BASE is required (global/repo var or secret)."
exit 1
fi
if [ -z "${CLIENT_KEY:-}" ]; then
echo "CLIENT_KEY is required (secret)."
exit 1
fi
SKILL_BASE_DIR="${SKILL_SUBPATH:-.}"
if [ -z "${SKILL_SLUG:-}" ]; then
if [ -f "${SKILL_BASE_DIR}/package.json" ]; then
PKG_NAME=$(jq -r '.name // empty' "${SKILL_BASE_DIR}/package.json")
if [ -n "$PKG_NAME" ]; then
# Strip npm scope: @scope/skill-name -> skill-name
SKILL_SLUG="${PKG_NAME##*/}"
fi
fi
fi
if [ -z "${SKILL_SLUG:-}" ]; then
if [ -f "${SKILL_BASE_DIR}/pyproject.toml" ]; then
PYPROJECT_NAME=$(python3 -c "import sys,tomllib; p=sys.argv[1]; d=tomllib.load(open(p,'rb')); print((d.get('project',{}).get('name') or d.get('tool',{}).get('poetry',{}).get('name') or ''))" "${SKILL_BASE_DIR}/pyproject.toml" 2>/dev/null || true)
if [ -n "$PYPROJECT_NAME" ]; then
SKILL_SLUG="${PYPROJECT_NAME##*/}"
fi
fi
fi
if [ -z "${SKILL_SLUG:-}" ]; then
SKILL_SLUG="${GITHUB_REPOSITORY##*/}"
fi
SESSION_RES=$(curl -sS -X POST "${API_BASE}/auth/skill-credit/session" \
-H "Content-Type: application/json" \
-d "{\"clientKey\":\"${CLIENT_KEY}\"}")
ACCESS_TOKEN=$(printf '%s' "$SESSION_RES" | jq -r '.accessToken // empty')
if [ -z "$ACCESS_TOKEN" ]; then
echo "failed to exchange access token from client key"
echo "$SESSION_RES"
exit 1
fi
RUNTIME_META=$(jq -nc --arg entry "${SKILL_SUBPATH:+$SKILL_SUBPATH/}scripts" '{entry_hint:$entry, provider:"forgejo"}')
cat > /tmp/register_payload.json <<JSON
{
"skill_slug": "${SKILL_SLUG}",
"version": "${SKILL_VERSION}",
"release_note": $(printf '%s' "${RELEASE_NOTE:-}" | jq -Rs .),
"source_type": "git_ci",
"repo_url": "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git",
"repo_subpath": "${SKILL_SUBPATH:-}",
"git_ref": "${GITHUB_REF_NAME}",
"commit_sha": "${GITHUB_SHA}",
"skill_doc_path": "${SKILL_DOC_PATH}",
"skill_doc_content": $(cat /tmp/skill_doc.json),
"runtime_meta": ${RUNTIME_META}
}
JSON
curl -sS -X POST "${API_BASE}/ecom/skills/register-by-slug" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d @/tmp/register_payload.json

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env.local
dist/

53
CONFIG_MIGRATION.md Normal file
View File

@ -0,0 +1,53 @@
# 配置迁移说明
## v2.0 重大变更
从 v2.0 开始,本 skill 不再使用 `.env.local` 文件,改为使用全局配置文件 `~/.openclaw/.env`
### 迁移步骤
1. **全局配置已自动创建**
```bash
~/.openclaw/.env
```
2. **删除本地 .env.local**
```bash
rm .env.local
```
3. **验证配置**
```bash
cat ~/.openclaw/.env
```
### 优势
- ✅ 一处配置,所有 skill 共享
- ✅ 更换 KEY 只需修改一个文件
- ✅ 新 skill 无需重复配置
### 配置优先级
1. 命令行参数(最高)
```bash
bun run scripts/run.ts --client-key=xxx scrape-url ...
```
2. 全局配置 `~/.openclaw/.env`
```bash
CLIENT_KEY=sk_xxx
```
3. 默认值
### 回滚到 v1.x不推荐
如果需要使用 `.env.local`
```bash
cp ~/.openclaw/.env .env.local
git checkout scripts/run.ts # 恢复旧版本
```
但建议使用新的全局配置模式。

47
SKILL.md Normal file
View File

@ -0,0 +1,47 @@
---
name: 1688-product-master
description: "Scrape 1688 product pages and return structured product data."
---
# 1688 Product Master
Scrape 1688 product pages with image/title/variant optimization.
## Run
```bash
bun dist/run.js <command> [args] [--dry-run]
```
### Commands
| Command | Description |
|---------|-------------|
| `session` | Get session token |
| `scrape-url <url> [translate]` | Scrape a 1688 URL |
| `scrape-payload <json>` | Scrape with custom payload |
### Examples
```bash
# Scrape a product URL
bun dist/run.js scrape-url 'https://detail.1688.com/offer/852504650877.html'
# With translation
bun dist/run.js scrape-url 'https://detail.1688.com/offer/852504650877.html' true
# Dry run
bun dist/run.js scrape-url 'https://detail.1688.com/offer/852504650877.html' --dry-run
```
## Output
Returns structured JSON with product data:
- Product info (title, price, description)
- Images (optimized)
- Variants/SKUs
- Supplier info
## Reference
See [references/1688-product-master.md](references/1688-product-master.md).

4
agents/openai.yaml Normal file
View File

@ -0,0 +1,4 @@
interface:
display_name: "1688 Product Master"
short_description: "Run 1688 scrape via client key"
default_prompt: "[skill:1688-product-master] For agents that already have CLIENT_KEY: exchange token via /auth/skill-credit/session, then call /ecom/tasks/scrape with scrape payload."

15
bun.lock Normal file
View File

@ -0,0 +1,15 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "1688-product-master",
"dependencies": {
"@clawd/auth-runtime": "git+http://192.168.0.108:3030/agent-skills/auth-runtime.git",
},
},
},
"packages": {
"@clawd/auth-runtime": ["@clawd/auth-runtime@git+http://192.168.0.108:3030/agent-skills/auth-runtime.git#70cf86889eecbe9c4649bb072cd971c3a560e889", {}, "70cf86889eecbe9c4649bb072cd971c3a560e889"],
}
}

24
install.sh Executable file
View File

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Install 1688-product-master to a target directory.
# Bundles the skill + auth-runtime into a single self-contained file.
#
# Usage:
# ./install.sh # installs to ~/.openclaw/skills/
# ./install.sh /custom/path/
set -euo pipefail
SKILL_NAME="1688-product-master"
DEST="${1:-$HOME/.openclaw/skills}"
cd "$(dirname "$0")"
echo "Building $SKILL_NAME..."
bun install --frozen-lockfile
bun build scripts/run.ts --outfile dist/run.js --target bun
mkdir -p "$DEST/$SKILL_NAME"
cp dist/run.js "$DEST/$SKILL_NAME/run.js"
echo "Installed: $DEST/$SKILL_NAME/run.js"
echo "Run with: bun $DEST/$SKILL_NAME/run.js <command> [args...]"

1
node_modules/@clawd/auth-runtime/.bun-tag generated vendored Normal file
View File

@ -0,0 +1 @@
70cf86889eecbe9c4649bb072cd971c3a560e889

1
node_modules/@clawd/auth-runtime/.gitignore generated vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

103
node_modules/@clawd/auth-runtime/README.md generated vendored Normal file
View File

@ -0,0 +1,103 @@
# @clawd/auth-runtime
Shared TypeScript auth runtime for OpenClaw skills.
## Features
- Token caching with configurable TTL
- Automatic token refresh
- Session-expired auto retry (401/403)
- Environment-based configuration
- Type-safe API
## Installation
```bash
bun add file:../../_shared/auth-runtime
```
## Usage
```typescript
import {
createEnvConfig,
getAccessToken,
requestApiWithAutoRefresh,
} from '@clawd/auth-runtime';
const config = createEnvConfig();
const token = await getAccessToken(false, config);
const result = await requestApiWithAutoRefresh(
'POST',
`${config.authBase}/ecom/tasks/scrape`,
false,
config,
JSON.stringify({ url: 'https://detail.1688.com/offer/123.html' }),
token,
);
```
## API
### Functions
#### `createEnvConfig(): EnvConfig`
Create configuration from environment variables:
- `AUTH_BASE`: Auth base URL (default: https://api-gw-test.yuanwei-lnc.com)
- `CLIENT_KEY`: Client key (required)
- `AUTH_CACHE_DIR`: Cache directory (default: /tmp/skill-auth-cache)
- `AUTH_MIN_TTL_SEC`: Minimum token TTL in seconds (default: 60)
#### `getAccessToken(dryRun, config): Promise<string>`
Get access token with caching.
#### `refreshAccessToken(dryRun, config): Promise<string>`
Refresh access token (bypass cache).
#### `fetchSessionJson(dryRun, config): Promise<SessionResponse>`
Fetch session JSON from auth endpoint.
#### `requestApi(method, url, authToken?, body?): Promise<ApiResponse>`
Make HTTP request with optional authorization header.
#### `isRetryableSessionError(response): boolean`
Check whether response likely indicates expired runtime session.
#### `requestApiWithAutoRefresh(method, url, dryRun, config, body?, accessToken?): Promise<ApiResponse>`
Call API with one automatic token refresh + retry on session-expired style errors.
### Types
#### `EnvConfig`
Environment configuration.
#### `SessionResponse`
Session response from auth endpoint.
#### `CachedTokenData`
Cached token data.
#### `HttpMethod`
Supported HTTP methods.
#### `ApiResponse`
HTTP response.
## Building
```bash
bun run build
```

20
node_modules/@clawd/auth-runtime/bun.lock generated vendored Normal file
View File

@ -0,0 +1,20 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@clawd/auth-runtime",
"devDependencies": {
"@types/node": "^25.3.3",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

33
node_modules/@clawd/auth-runtime/install.sh generated vendored Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Bootstrap auth-runtime into the skill store.
# Run this ONCE before installing any skills.
#
# Usage:
# curl -fsSL https://your-forgejo/raw/auth-runtime/main/install.sh | bash
# # or:
# ./install.sh [skill-store-dir]
set -euo pipefail
STORE="${1:-$HOME/.openclaw/skills}"
DEST="$STORE/_shared/auth-runtime"
REPO="${AUTH_RUNTIME_REPO:-}" # set via env or hardcode below
# Hardcode your Forgejo URL here:
# REPO="https://git.yourserver.com/you/auth-runtime.git"
if [[ -z "$REPO" ]]; then
echo "Error: set AUTH_RUNTIME_REPO=https://git.yourserver.com/you/auth-runtime.git"
exit 1
fi
if [[ -d "$DEST/.git" ]]; then
echo "Updating auth-runtime..."
git -C "$DEST" pull --ff-only
else
echo "Installing auth-runtime to $DEST..."
mkdir -p "$STORE/_shared"
git clone "$REPO" "$DEST"
fi
echo "auth-runtime ready at $DEST"

29
node_modules/@clawd/auth-runtime/package.json generated vendored Normal file
View File

@ -0,0 +1,29 @@
{
"name": "@clawd/auth-runtime",
"version": "1.0.0",
"description": "Shared TypeScript auth runtime for OpenClaw skills",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist"
},
"keywords": [
"auth",
"runtime",
"openclaw"
],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^25.3.3",
"typescript": "^5.9.3"
}
}

154
node_modules/@clawd/auth-runtime/src/auth.ts generated vendored Normal file
View File

@ -0,0 +1,154 @@
import type { ApiResponse, EnvConfig, HttpMethod, SessionResponse } from './types.js';
import { requestApi } from './http.js';
import { getCacheFile, readCachedToken, writeCache, deleteCache } from './cache.js';
const SESSION_RETRYABLE_STATUS = new Set([401, 403]);
const SESSION_RETRYABLE_BODY_MARKERS = [
'session not found or expired',
'invalid or expired token',
'unauthorized',
'client key expired',
'client key revoked',
];
/**
* Create environment configuration from process.env
*/
export function createEnvConfig(): EnvConfig {
return {
authBase: (process.env.AUTH_BASE || 'https://api-gw-test.yuanwei-lnc.com').replace(/\/$/, ''),
clientKey: process.env.CLIENT_KEY || '',
authCacheDir: process.env.AUTH_CACHE_DIR || '/tmp/skill-auth-cache',
authMinTtlSec: parseInt(process.env.AUTH_MIN_TTL_SEC || '60', 10),
};
}
/**
* Fetch session JSON from auth endpoint
*/
export async function fetchSessionJson(
dryRun: boolean,
config: EnvConfig
): Promise<SessionResponse> {
if (dryRun) {
return {
accessToken: '<dry-run-token>',
hookUrl: '<dry-run-hook-url>',
hookToken: '<dry-run-hook-token>',
expiresIn: 900,
};
}
if (!config.clientKey) {
throw new Error('CLIENT_KEY is required');
}
const payload = JSON.stringify({ clientKey: config.clientKey });
const result = await requestApi(
'POST',
`${config.authBase}/auth/skill-credit/session`,
undefined,
payload
);
if (result.status < 200 || result.status >= 300) {
throw new Error(`Auth session request failed: HTTP ${result.status} - ${result.body}`);
}
const session = JSON.parse(result.body) as SessionResponse;
if (!session.accessToken) {
throw new Error(`Missing accessToken in session response: ${result.body}`);
}
return session;
}
/**
* Get access token with caching
*/
export async function getAccessToken(
dryRun: boolean,
config: EnvConfig
): Promise<string> {
if (dryRun) {
return '<dry-run-token>';
}
if (!config.clientKey) {
throw new Error('CLIENT_KEY is required');
}
const cacheFile = getCacheFile(config.authBase, config.clientKey, config.authCacheDir);
const cachedToken = readCachedToken(cacheFile, config.authMinTtlSec);
if (cachedToken) {
return cachedToken;
}
const session = await fetchSessionJson(dryRun, config);
writeCache(cacheFile, session);
return session.accessToken;
}
/**
* Refresh access token (bypass cache)
*/
export async function refreshAccessToken(
dryRun: boolean,
config: EnvConfig
): Promise<string> {
if (dryRun) {
return '<dry-run-token>';
}
if (!config.clientKey) {
throw new Error('CLIENT_KEY is required');
}
const cacheFile = getCacheFile(config.authBase, config.clientKey, config.authCacheDir);
// Remove cache file if exists
deleteCache(cacheFile);
return getAccessToken(dryRun, config);
}
/**
* Check whether response likely indicates expired/invalid runtime session.
*/
export function isRetryableSessionError(response: ApiResponse): boolean {
if (!SESSION_RETRYABLE_STATUS.has(response.status)) {
return false;
}
const body = (response.body || '').toLowerCase();
if (!body) {
return true;
}
return SESSION_RETRYABLE_BODY_MARKERS.some((marker) => body.includes(marker));
}
/**
* Make API request with automatic runtime token refresh and one retry.
*/
export async function requestApiWithAutoRefresh(
method: HttpMethod,
url: string,
dryRun: boolean,
config: EnvConfig,
body?: string,
accessToken?: string,
): Promise<ApiResponse> {
const token = accessToken || await getAccessToken(dryRun, config);
const first = await requestApi(method, url, token, body);
if (!isRetryableSessionError(first)) {
return first;
}
const freshToken = await refreshAccessToken(dryRun, config);
return requestApi(method, url, freshToken, body);
}

83
node_modules/@clawd/auth-runtime/src/cache.ts generated vendored Normal file
View File

@ -0,0 +1,83 @@
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import type { CachedTokenData } from './types.js';
/**
* Generate SHA256 hash for cache key
*/
export function sha256(input: string): string {
return crypto.createHash('sha256').update(input).digest('hex');
}
/**
* Get cache file path for current AUTH_BASE and CLIENT_KEY
*/
export function getCacheFile(authBase: string, clientKey: string, cacheDir: string): string {
const key = sha256(`${authBase}|${clientKey}`);
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
return path.join(cacheDir, `session_${key}.json`);
}
/**
* Read cached token if valid
*/
export function readCachedToken(
cacheFile: string,
minTtlSec: number
): string | null {
if (!fs.existsSync(cacheFile)) {
return null;
}
try {
const data = JSON.parse(fs.readFileSync(cacheFile, 'utf-8')) as CachedTokenData;
const now = Math.floor(Date.now() / 1000);
if (!data.accessToken || data.expiresAtEpoch <= 0) {
return null;
}
// Check if token is still valid (with min TTL buffer)
if (now + minTtlSec >= data.expiresAtEpoch) {
return null;
}
return data.accessToken;
} catch (error) {
return null;
}
}
/**
* Write token to cache
*/
export function writeCache(
cacheFile: string,
sessionJson: { accessToken: string; expiresIn: number }
): void {
const nowEpoch = Math.floor(Date.now() / 1000);
const expiresIn = sessionJson.expiresIn > 0 ? sessionJson.expiresIn : 900;
const expiresAtEpoch = nowEpoch + expiresIn;
const cacheData: CachedTokenData = {
accessToken: sessionJson.accessToken,
expiresAtEpoch,
createdAtEpoch: nowEpoch,
};
fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2), 'utf-8');
}
/**
* Delete cache file if exists
*/
export function deleteCache(cacheFile: string): void {
if (fs.existsSync(cacheFile)) {
fs.unlinkSync(cacheFile);
}
}

36
node_modules/@clawd/auth-runtime/src/http.ts generated vendored Normal file
View File

@ -0,0 +1,36 @@
import type { ApiResponse, HttpMethod } from './types.js';
/**
* Make HTTP request to API
*/
export async function requestApi(
method: HttpMethod,
url: string,
authToken?: string,
body?: string
): Promise<ApiResponse> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const options: RequestInit = {
method,
headers,
};
if (body) {
options.body = body;
}
const response = await fetch(url, options);
const responseBody = await response.text();
return {
status: response.status,
body: responseBody,
};
}

38
node_modules/@clawd/auth-runtime/src/index.ts generated vendored Normal file
View File

@ -0,0 +1,38 @@
/**
* @clawd/auth-runtime
*
* Shared TypeScript auth runtime for OpenClaw skills.
*
* Provides authentication, token caching, and HTTP utilities.
*/
// Auth functions
export {
createEnvConfig,
fetchSessionJson,
getAccessToken,
refreshAccessToken,
isRetryableSessionError,
requestApiWithAutoRefresh,
} from './auth.js';
// HTTP utilities
export { requestApi } from './http.js';
// Cache utilities
export {
sha256,
getCacheFile,
readCachedToken,
writeCache,
deleteCache,
} from './cache.js';
// Types
export type {
EnvConfig,
SessionResponse,
CachedTokenData,
ApiResponse,
HttpMethod,
} from './types.js';

46
node_modules/@clawd/auth-runtime/src/types.ts generated vendored Normal file
View File

@ -0,0 +1,46 @@
/**
* Environment configuration for auth runtime
*/
export interface EnvConfig {
/** Authentication base URL */
authBase: string;
/** Client key for authentication */
clientKey: string;
/** Directory for storing auth cache files */
authCacheDir: string;
/** Minimum TTL for cached tokens in seconds */
authMinTtlSec: number;
}
/**
* Session response from /auth/skill-credit/session
*/
export interface SessionResponse {
accessToken: string;
ownerSessionToken?: string;
hookUrl?: string;
hookToken?: string;
expiresIn: number;
}
/**
* Cached token data stored on disk
*/
export interface CachedTokenData {
accessToken: string;
expiresAtEpoch: number;
createdAtEpoch: number;
}
/**
* HTTP method used by requestApi
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
/**
* HTTP API response
*/
export interface ApiResponse {
status: number;
body: string;
}

20
node_modules/@clawd/auth-runtime/tsconfig.json generated vendored Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

13
package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "1688-product-master",
"version": "1.0.0",
"type": "module",
"scripts": {
"run": "bun run scripts/run.ts",
"build": "bun build scripts/run.ts --outfile dist/run.js --target bun",
"package": "bun run build && cd .. && zip -r 1688-product-master.skill 1688-product-master/SKILL.md 1688-product-master/dist/run.js && echo 'Created: 1688-product-master.skill'"
},
"dependencies": {
"@clawd/auth-runtime": "git+http://192.168.0.108:3030/agent-skills/auth-runtime.git"
}
}

View File

@ -0,0 +1,33 @@
# 1688 Product Master Reference
## 1. Runtime scrape mapping from original curl
Original browser call target:
- `POST /ecom/tasks/scrape`
Runtime script behavior:
1. Exchange client key:
- `POST /auth/skill-credit/session`
- body: `{ "clientKey": "<CLIENT_KEY>" }`
2. Use returned `accessToken`:
- `Authorization: Bearer <accessToken>`
3. Call scrape:
- `POST /ecom/tasks/scrape`
- `Content-Type: application/json`
- payload fields:
- `url`
- `optimizeImages`
- `optimizeTitles`
- `optimizeVariants`
- `needTranslate`
4. If runtime session is expired (`401/403`), `@clawd/auth-runtime` will refresh token and retry once automatically.
The extra browser headers in the original curl (`sec-*`, `origin`, cookies, etc.) are not required by this skill flow.
## 2. Notes
- `clientKey` plaintext is only returned at key creation time.
- Store the returned `clientKey` securely and inject it as `CLIENT_KEY`.
- `/auth/skill-credit/clients*` endpoints are owner management APIs and are out of this runtime skill scope.

271
scripts/1688-product-master.sh Executable file
View File

@ -0,0 +1,271 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
1688-product-master.sh <command> [args...] [--dry-run]
Commands:
session
scrape-url <1688-url> [need-translate:true|false]
scrape-payload <payload-json>
Examples:
CLIENT_KEY=<sk_xxx.yyy> 1688-product-master.sh scrape-url 'https://detail.1688.com/offer/852504650877.html'
CLIENT_KEY=<sk_xxx.yyy> 1688-product-master.sh scrape-payload '{"url":"https://detail.1688.com/offer/852504650877.html","optimizeImages":true,"optimizeTitles":true,"optimizeVariants":true,"needTranslate":false}'
EOF
}
AUTH_BASE="${AUTH_BASE:-https://api-gw-test.yuanwei-lnc.com}"
AUTH_BASE="${AUTH_BASE%/}"
ECOM_BASE="${ECOM_BASE:-$AUTH_BASE}"
ECOM_BASE="${ECOM_BASE%/}"
CLIENT_KEY="${CLIENT_KEY:-}"
DEFAULT_OPTIMIZE_IMAGES="${DEFAULT_OPTIMIZE_IMAGES:-true}"
DEFAULT_OPTIMIZE_TITLES="${DEFAULT_OPTIMIZE_TITLES:-true}"
DEFAULT_OPTIMIZE_VARIANTS="${DEFAULT_OPTIMIZE_VARIANTS:-true}"
DEFAULT_NEED_TRANSLATE="${DEFAULT_NEED_TRANSLATE:-false}"
DRY_RUN=0
POSITIONALS=()
for arg in "$@"; do
case "$arg" in
--dry-run)
DRY_RUN=1
;;
-h|--help)
usage
exit 0
;;
*)
POSITIONALS+=("$arg")
;;
esac
done
if [ "${#POSITIONALS[@]}" -lt 1 ]; then
usage
exit 1
fi
COMMAND="${POSITIONALS[0]}"
if [ -z "$CLIENT_KEY" ]; then
echo "Missing CLIENT_KEY." >&2
exit 1
fi
request_api() {
local method="$1"
local url="$2"
local auth_header="${3:-}"
local body="${4:-}"
local tmp_body status
tmp_body="$(mktemp)"
local curl_args=(-sS -o "$tmp_body" -w "%{http_code}" -X "$method" "$url")
if [ -n "$auth_header" ]; then
curl_args+=(-H "Authorization: Bearer $auth_header")
fi
if [ -n "$body" ]; then
curl_args+=(-H "Content-Type: application/json" --data "$body")
fi
status="$(curl "${curl_args[@]}")"
local response
response="$(cat "$tmp_body")"
rm -f "$tmp_body"
printf '%s\t%s\n' "$status" "$response"
}
extract_status() { printf '%s' "${1%%$'\t'*}"; }
extract_body() { printf '%s' "${1#*$'\t'}"; }
require_2xx() {
local status="$1"
local body="$2"
local context="$3"
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
echo "Request failed at $context: HTTP $status" >&2
echo "$body" >&2
exit 1
fi
}
to_bool_json() {
local raw="$1"
python3 - "$raw" <<'PY'
import sys
v = (sys.argv[1] or "").strip().lower()
print("true" if v in ("1", "true", "yes", "y") else "false")
PY
}
build_payload_from_url() {
local url="$1"
local need_translate_override="${2:-}"
python3 - "$url" "$DEFAULT_OPTIMIZE_IMAGES" "$DEFAULT_OPTIMIZE_TITLES" "$DEFAULT_OPTIMIZE_VARIANTS" "$DEFAULT_NEED_TRANSLATE" "$need_translate_override" <<'PY'
import json
import sys
url = (sys.argv[1] or "").strip()
if not url:
raise SystemExit("url is required")
def as_bool(raw):
return str(raw).strip().lower() in ("1", "true", "yes", "y")
payload = {
"url": url,
"optimizeImages": as_bool(sys.argv[2]),
"optimizeTitles": as_bool(sys.argv[3]),
"optimizeVariants": as_bool(sys.argv[4]),
"needTranslate": as_bool(sys.argv[5]),
}
override = (sys.argv[6] or "").strip()
if override:
payload["needTranslate"] = as_bool(override)
print(json.dumps(payload, ensure_ascii=False))
PY
}
validate_payload_json() {
local raw="$1"
python3 - "$raw" <<'PY'
import json
import sys
raw = sys.argv[1]
try:
data = json.loads(raw)
except Exception as exc:
raise SystemExit(f"invalid payload json: {exc}")
if not isinstance(data, dict):
raise SystemExit("payload must be a JSON object")
if not data.get("url"):
raise SystemExit("payload.url is required")
print(json.dumps(data, ensure_ascii=False))
PY
}
get_access_token() {
local session_payload
session_payload="$(python3 - "$CLIENT_KEY" <<'PY'
import json,sys
print(json.dumps({"clientKey": sys.argv[1]}, ensure_ascii=False))
PY
)"
if [ "$DRY_RUN" -eq 1 ]; then
echo '{"accessToken":"<dry-run-token>","ownerSessionToken":"<dry-run-owner-token>","expiresAt":"2099-01-01T00:00:00.000Z"}'
return
fi
local session_result session_status session_body
session_result="$(request_api "POST" "$AUTH_BASE/auth/skill-credit/session" "" "$session_payload")"
session_status="$(extract_status "$session_result")"
session_body="$(extract_body "$session_result")"
require_2xx "$session_status" "$session_body" "skill session"
echo "$session_body"
}
json_get() {
local raw="$1"
local key="$2"
python3 - "$raw" "$key" <<'PY'
import json,sys
raw = sys.argv[1]
key = sys.argv[2]
try:
data = json.loads(raw)
except Exception:
print("")
raise SystemExit(0)
value = data.get(key, "")
if value is None:
value = ""
print(value)
PY
}
cmd_session() {
local session_json
session_json="$(get_access_token)"
echo "$session_json"
}
cmd_scrape_url() {
local url="${POSITIONALS[1]:-}"
local need_translate="${POSITIONALS[2]:-}"
if [ -z "$url" ]; then
echo "scrape-url requires <1688-url>" >&2
exit 1
fi
local payload
payload="$(build_payload_from_url "$url" "$need_translate")"
run_scrape_with_payload "$payload"
}
cmd_scrape_payload() {
local raw_payload="${POSITIONALS[1]:-}"
if [ -z "$raw_payload" ]; then
echo "scrape-payload requires <payload-json>" >&2
exit 1
fi
local payload
payload="$(validate_payload_json "$raw_payload")"
run_scrape_with_payload "$payload"
}
run_scrape_with_payload() {
local payload="$1"
local session_json access_token
session_json="$(get_access_token)"
access_token="$(json_get "$session_json" "accessToken")"
if [ -z "$access_token" ]; then
echo "missing accessToken from /auth/skill-credit/session response" >&2
echo "$session_json" >&2
exit 1
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "curl -sS -X POST \"$ECOM_BASE/ecom/tasks/scrape\" -H \"Authorization: Bearer <accessToken>\" -H \"Content-Type: application/json\" --data '$payload'"
return
fi
local scrape_result scrape_status scrape_body
scrape_result="$(request_api "POST" "$ECOM_BASE/ecom/tasks/scrape" "$access_token" "$payload")"
scrape_status="$(extract_status "$scrape_result")"
scrape_body="$(extract_body "$scrape_result")"
require_2xx "$scrape_status" "$scrape_body" "ecom scrape"
python3 - "$session_json" "$scrape_status" "$scrape_body" "$payload" <<'PY'
import json
import sys
session_raw, scrape_status, scrape_body_raw, payload_raw = sys.argv[1:]
def parse_json(raw):
try:
return json.loads(raw)
except Exception:
return {"raw": raw}
result = {
"status": "SUCCESS",
"requestPayload": parse_json(payload_raw),
"session": parse_json(session_raw),
"scrape": {
"httpStatus": int(scrape_status),
"body": parse_json(scrape_body_raw),
}
}
print(json.dumps(result, ensure_ascii=False))
PY
}
case "$COMMAND" in
session) cmd_session ;;
scrape-url) cmd_scrape_url ;;
scrape-payload) cmd_scrape_payload ;;
*)
echo "Unknown command: $COMMAND" >&2
usage
exit 1
;;
esac

View File

@ -0,0 +1,271 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
1688-product-master.sh <command> [args...] [--dry-run]
Commands:
session
scrape-url <1688-url> [need-translate:true|false]
scrape-payload <payload-json>
Examples:
CLIENT_KEY=<sk_xxx.yyy> 1688-product-master.sh scrape-url 'https://detail.1688.com/offer/852504650877.html'
CLIENT_KEY=<sk_xxx.yyy> 1688-product-master.sh scrape-payload '{"url":"https://detail.1688.com/offer/852504650877.html","optimizeImages":true,"optimizeTitles":true,"optimizeVariants":true,"needTranslate":false}'
EOF
}
AUTH_BASE="${AUTH_BASE:-https://api-gw-test.yuanwei-lnc.com}"
AUTH_BASE="${AUTH_BASE%/}"
ECOM_BASE="${ECOM_BASE:-$AUTH_BASE}"
ECOM_BASE="${ECOM_BASE%/}"
CLIENT_KEY="${CLIENT_KEY:-}"
DEFAULT_OPTIMIZE_IMAGES="${DEFAULT_OPTIMIZE_IMAGES:-true}"
DEFAULT_OPTIMIZE_TITLES="${DEFAULT_OPTIMIZE_TITLES:-true}"
DEFAULT_OPTIMIZE_VARIANTS="${DEFAULT_OPTIMIZE_VARIANTS:-true}"
DEFAULT_NEED_TRANSLATE="${DEFAULT_NEED_TRANSLATE:-false}"
DRY_RUN=0
POSITIONALS=()
for arg in "$@"; do
case "$arg" in
--dry-run)
DRY_RUN=1
;;
-h|--help)
usage
exit 0
;;
*)
POSITIONALS+=("$arg")
;;
esac
done
if [ "${#POSITIONALS[@]}" -lt 1 ]; then
usage
exit 1
fi
COMMAND="${POSITIONALS[0]}"
if [ -z "$CLIENT_KEY" ]; then
echo "Missing CLIENT_KEY." >&2
exit 1
fi
request_api() {
local method="$1"
local url="$2"
local auth_header="${3:-}"
local body="${4:-}"
local tmp_body status
tmp_body="$(mktemp)"
local curl_args=(-sS -o "$tmp_body" -w "%{http_code}" -X "$method" "$url")
if [ -n "$auth_header" ]; then
curl_args+=(-H "Authorization: Bearer $auth_header")
fi
if [ -n "$body" ]; then
curl_args+=(-H "Content-Type: application/json" --data "$body")
fi
status="$(curl "${curl_args[@]}")"
local response
response="$(cat "$tmp_body")"
rm -f "$tmp_body"
printf '%s\t%s\n' "$status" "$response"
}
extract_status() { printf '%s' "${1%%$'\t'*}"; }
extract_body() { printf '%s' "${1#*$'\t'}"; }
require_2xx() {
local status="$1"
local body="$2"
local context="$3"
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
echo "Request failed at $context: HTTP $status" >&2
echo "$body" >&2
exit 1
fi
}
to_bool_json() {
local raw="$1"
python3 - "$raw" <<'PY'
import sys
v = (sys.argv[1] or "").strip().lower()
print("true" if v in ("1", "true", "yes", "y") else "false")
PY
}
build_payload_from_url() {
local url="$1"
local need_translate_override="${2:-}"
python3 - "$url" "$DEFAULT_OPTIMIZE_IMAGES" "$DEFAULT_OPTIMIZE_TITLES" "$DEFAULT_OPTIMIZE_VARIANTS" "$DEFAULT_NEED_TRANSLATE" "$need_translate_override" <<'PY'
import json
import sys
url = (sys.argv[1] or "").strip()
if not url:
raise SystemExit("url is required")
def as_bool(raw):
return str(raw).strip().lower() in ("1", "true", "yes", "y")
payload = {
"url": url,
"optimizeImages": as_bool(sys.argv[2]),
"optimizeTitles": as_bool(sys.argv[3]),
"optimizeVariants": as_bool(sys.argv[4]),
"needTranslate": as_bool(sys.argv[5]),
}
override = (sys.argv[6] or "").strip()
if override:
payload["needTranslate"] = as_bool(override)
print(json.dumps(payload, ensure_ascii=False))
PY
}
validate_payload_json() {
local raw="$1"
python3 - "$raw" <<'PY'
import json
import sys
raw = sys.argv[1]
try:
data = json.loads(raw)
except Exception as exc:
raise SystemExit(f"invalid payload json: {exc}")
if not isinstance(data, dict):
raise SystemExit("payload must be a JSON object")
if not data.get("url"):
raise SystemExit("payload.url is required")
print(json.dumps(data, ensure_ascii=False))
PY
}
get_access_token() {
local session_payload
session_payload="$(python3 - "$CLIENT_KEY" <<'PY'
import json,sys
print(json.dumps({"clientKey": sys.argv[1]}, ensure_ascii=False))
PY
)"
if [ "$DRY_RUN" -eq 1 ]; then
echo '{"accessToken":"<dry-run-token>","ownerSessionToken":"<dry-run-owner-token>","expiresAt":"2099-01-01T00:00:00.000Z"}'
return
fi
local session_result session_status session_body
session_result="$(request_api "POST" "$AUTH_BASE/auth/skill-credit/session" "" "$session_payload")"
session_status="$(extract_status "$session_result")"
session_body="$(extract_body "$session_result")"
require_2xx "$session_status" "$session_body" "skill session"
echo "$session_body"
}
json_get() {
local raw="$1"
local key="$2"
python3 - "$raw" "$key" <<'PY'
import json,sys
raw = sys.argv[1]
key = sys.argv[2]
try:
data = json.loads(raw)
except Exception:
print("")
raise SystemExit(0)
value = data.get(key, "")
if value is None:
value = ""
print(value)
PY
}
cmd_session() {
local session_json
session_json="$(get_access_token)"
echo "$session_json"
}
cmd_scrape_url() {
local url="${POSITIONALS[1]:-}"
local need_translate="${POSITIONALS[2]:-}"
if [ -z "$url" ]; then
echo "scrape-url requires <1688-url>" >&2
exit 1
fi
local payload
payload="$(build_payload_from_url "$url" "$need_translate")"
run_scrape_with_payload "$payload"
}
cmd_scrape_payload() {
local raw_payload="${POSITIONALS[1]:-}"
if [ -z "$raw_payload" ]; then
echo "scrape-payload requires <payload-json>" >&2
exit 1
fi
local payload
payload="$(validate_payload_json "$raw_payload")"
run_scrape_with_payload "$payload"
}
run_scrape_with_payload() {
local payload="$1"
local session_json access_token
session_json="$(get_access_token)"
access_token="$(json_get "$session_json" "accessToken")"
if [ -z "$access_token" ]; then
echo "missing accessToken from /auth/skill-credit/session response" >&2
echo "$session_json" >&2
exit 1
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "curl -sS -X POST \"$ECOM_BASE/ecom/tasks/scrape\" -H \"Authorization: Bearer <accessToken>\" -H \"Content-Type: application/json\" --data '$payload'"
return
fi
local scrape_result scrape_status scrape_body
scrape_result="$(request_api "POST" "$ECOM_BASE/ecom/tasks/scrape" "$access_token" "$payload")"
scrape_status="$(extract_status "$scrape_result")"
scrape_body="$(extract_body "$scrape_result")"
require_2xx "$scrape_status" "$scrape_body" "ecom scrape"
python3 - "$session_json" "$scrape_status" "$scrape_body" "$payload" <<'PY'
import json
import sys
session_raw, scrape_status, scrape_body_raw, payload_raw = sys.argv[1:]
def parse_json(raw):
try:
return json.loads(raw)
except Exception:
return {"raw": raw}
result = {
"status": "SUCCESS",
"requestPayload": parse_json(payload_raw),
"session": parse_json(session_raw),
"scrape": {
"httpStatus": int(scrape_status),
"body": parse_json(scrape_body_raw),
}
}
print(json.dumps(result, ensure_ascii=False))
PY
}
case "$COMMAND" in
session) cmd_session ;;
scrape-url) cmd_scrape_url ;;
scrape-payload) cmd_scrape_payload ;;
*)
echo "Unknown command: $COMMAND" >&2
usage
exit 1
;;
esac

107
scripts/run.ts Executable file
View File

@ -0,0 +1,107 @@
#!/usr/bin/env bun
import type { Command } from '../src/types.js';
import { run1688 } from '../src/index.js';
/**
* v2.0 .env.local
* ~/.openclaw/.env
*
* skill skill
*
*
* cp ~/.openclaw/.env.example ~/.openclaw/.env
* vi ~/.openclaw/.env # CLIENT_KEY
*/
function printUsage(): void {
console.error(`Usage:
bun run scripts/run.ts [--client-key=<key>] [--auth-base=<url>] [--ecom-base=<url>] <command> [args...] [--dry-run]
Commands:
session
scrape-url <1688-url> [translate]
scrape-payload <payload-json>
Examples:
bun run scripts/run.ts scrape-url 'https://detail.1688.com/offer/852504650877.html'
bun run scripts/run.ts scrape-url 'https://detail.1688.com/offer/852504650877.html' true
bun run scripts/run.ts scrape-url 'https://detail.1688.com/offer/852504650877.html' --dry-run
bun run scripts/run.ts scrape-payload '{"url":"https://detail.1688.com/offer/852504650877.html"}'
:
~/.openclaw/.env
`);
}
type CliArgs = {
command: Command;
args: string[];
dryRun: boolean;
clientKey?: string;
authBase?: string;
ecomBase?: string;
};
function parseArgs(argv: string[]): CliArgs | null {
const positionals: string[] = [];
let dryRun = false;
let clientKey: string | undefined;
let authBase: string | undefined;
let ecomBase: string | undefined;
for (const arg of argv) {
if (arg === '--dry-run') {
dryRun = true;
} else if (arg.startsWith('--client-key=')) {
clientKey = arg.slice('--client-key='.length).trim();
} else if (arg.startsWith('--auth-base=')) {
authBase = arg.slice('--auth-base='.length).trim().replace(/\/$/, '');
} else if (arg.startsWith('--ecom-base=')) {
ecomBase = arg.slice('--ecom-base='.length).trim().replace(/\/$/, '');
} else if (arg === '-h' || arg === '--help') {
printUsage();
process.exit(0);
} else {
positionals.push(arg);
}
}
if (positionals.length < 1) {
return null;
}
const command = positionals[0] as Command;
const args = positionals.slice(1);
return { command, args, dryRun, clientKey, authBase, ecomBase };
}
async function main(): Promise<void> {
// 不再加载 .env.local直接使用全局配置 ~/.openclaw/.env
// auth-runtime 会自动加载全局配置
const parsed = parseArgs(process.argv.slice(2));
if (!parsed) {
printUsage();
process.exit(1);
}
// 命令行参数覆盖全局配置
if (parsed.clientKey) process.env.CLIENT_KEY = parsed.clientKey;
if (parsed.authBase) process.env.AUTH_BASE = parsed.authBase;
if (parsed.ecomBase) process.env.ECOM_BASE = parsed.ecomBase;
const result = await run1688(parsed.command, parsed.args, parsed.dryRun);
console.log(JSON.stringify(result, null, 2));
}
main().catch((error) => {
console.error(JSON.stringify({
status: 'failed',
error: error instanceof Error ? error.message : String(error),
command: '',
dryRun: false,
}, null, 2));
process.exit(1);
});

149
src/index.ts Normal file
View File

@ -0,0 +1,149 @@
import type { Command, OutputResult, ScrapePayload } from './types.js';
import { createEnvConfig, getAccessToken, fetchSessionJson } from '@clawd/auth-runtime';
import { buildPayloadFromUrl, validatePayloadJson, scrapeProduct } from './scrape.js';
export async function run1688(
command: Command,
args: string[],
dryRun: boolean = false,
): Promise<OutputResult> {
const config = createEnvConfig();
const ecomBase = (process.env.ECOM_BASE || config.authBase).replace(/\/$/, '');
if (!config.clientKey) {
return failed(command, dryRun, 'missing required env: CLIENT_KEY');
}
switch (command) {
case 'session':
return runSession(command, dryRun, config);
case 'scrape-url':
return runScrapeUrl(command, dryRun, config, ecomBase, args);
case 'scrape-payload':
return runScrapePayload(command, dryRun, config, ecomBase, args);
default:
return failed(command, dryRun, `unknown command: ${command}`);
}
}
async function runSession(
command: string,
dryRun: boolean,
config: ReturnType<typeof createEnvConfig>,
): Promise<OutputResult> {
const session = await fetchSessionJson(dryRun, config);
return { status: 'success', error: null, command, dryRun, session };
}
async function runScrapeUrl(
command: string,
dryRun: boolean,
config: ReturnType<typeof createEnvConfig>,
ecomBase: string,
args: string[],
): Promise<OutputResult> {
const url = args[0];
if (!url) {
return failed(command, dryRun, 'scrape-url requires <1688-url>');
}
const defaults = readDefaults();
const payload = buildPayloadFromUrl(url, args[1] || '', defaults);
return runScrape(command, dryRun, config, ecomBase, payload);
}
async function runScrapePayload(
command: string,
dryRun: boolean,
config: ReturnType<typeof createEnvConfig>,
ecomBase: string,
args: string[],
): Promise<OutputResult> {
const rawPayload = args[0];
if (!rawPayload) {
return failed(command, dryRun, 'scrape-payload requires <payload-json>');
}
let payload: ScrapePayload;
try {
payload = validatePayloadJson(rawPayload);
} catch (error) {
return failed(command, dryRun, error instanceof Error ? error.message : String(error));
}
return runScrape(command, dryRun, config, ecomBase, payload);
}
async function runScrape(
command: string,
dryRun: boolean,
config: ReturnType<typeof createEnvConfig>,
ecomBase: string,
payload: ScrapePayload,
): Promise<OutputResult> {
if (dryRun) {
return { status: 'success', error: null, command, dryRun, requestPayload: payload, scrapeHttpStatus: 0, scrapeBody: null };
}
let accessToken: string;
try {
accessToken = await getAccessToken(dryRun, config);
} catch (error) {
return failed(command, dryRun, error instanceof Error ? error.message : 'failed to get access token');
}
const result = await scrapeProduct(config, ecomBase, payload, dryRun, accessToken);
if (result.status < 200 || result.status >= 300) {
return failed(command, dryRun, `scrape failed: HTTP ${result.status}: ${result.body}`, payload, result.status);
}
return {
status: 'success',
error: null,
command,
dryRun,
requestPayload: payload,
scrapeHttpStatus: result.status,
scrapeBody: parseJsonSafe(result.body),
};
}
function readDefaults() {
return {
optimizeImages: parseBoolean(process.env.DEFAULT_OPTIMIZE_IMAGES ?? 'true'),
optimizeTitles: parseBoolean(process.env.DEFAULT_OPTIMIZE_TITLES ?? 'true'),
optimizeVariants: parseBoolean(process.env.DEFAULT_OPTIMIZE_VARIANTS ?? 'true'),
needTranslate: parseBoolean(process.env.DEFAULT_NEED_TRANSLATE ?? 'false'),
};
}
function parseBoolean(value: unknown): boolean {
const str = String(value).trim().toLowerCase();
return ['1', 'true', 'yes', 'y'].includes(str);
}
function parseJsonSafe(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return { raw };
}
}
function failed(
command: string,
dryRun: boolean,
error: string,
requestPayload?: ScrapePayload,
scrapeHttpStatus?: number,
): OutputResult {
return {
status: 'failed',
error,
command,
dryRun,
...(requestPayload && { requestPayload }),
...(scrapeHttpStatus !== undefined && { scrapeHttpStatus }),
};
}

82
src/scrape.ts Normal file
View File

@ -0,0 +1,82 @@
import type { ScrapePayload } from './types.js';
import { requestApiWithAutoRefresh } from '@clawd/auth-runtime';
import type { ApiResponse, EnvConfig } from '@clawd/auth-runtime';
type Defaults = {
optimizeImages: boolean;
optimizeTitles: boolean;
optimizeVariants: boolean;
needTranslate: boolean;
};
export function buildPayloadFromUrl(
url: string,
needTranslateOverride: string,
defaults: Defaults,
): ScrapePayload {
if (!url || url.trim() === '') {
throw new Error('url is required');
}
const payload: ScrapePayload = {
url: url.trim(),
optimizeImages: defaults.optimizeImages,
optimizeTitles: defaults.optimizeTitles,
optimizeVariants: defaults.optimizeVariants,
needTranslate: defaults.needTranslate,
};
if (needTranslateOverride && needTranslateOverride.trim() !== '') {
payload.needTranslate = parseBoolean(needTranslateOverride);
}
return payload;
}
export function validatePayloadJson(raw: string): ScrapePayload {
let data: unknown;
try {
data = JSON.parse(raw);
} catch (error) {
throw new Error(`invalid payload json: ${(error as SyntaxError).message}`);
}
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
throw new Error('payload must be a JSON object');
}
const obj = data as Record<string, unknown>;
if (!obj.url) {
throw new Error('payload.url is required');
}
return {
url: obj.url as string,
optimizeImages: parseBoolean(obj.optimizeImages ?? true),
optimizeTitles: parseBoolean(obj.optimizeTitles ?? true),
optimizeVariants: parseBoolean(obj.optimizeVariants ?? true),
needTranslate: parseBoolean(obj.needTranslate ?? false),
};
}
export async function scrapeProduct(
config: EnvConfig,
ecomBase: string,
payload: ScrapePayload,
dryRun: boolean,
accessToken?: string,
): Promise<ApiResponse> {
return requestApiWithAutoRefresh(
'POST',
`${ecomBase}/ecom/tasks/scrape`,
dryRun,
config,
JSON.stringify(payload),
accessToken,
);
}
function parseBoolean(value: unknown): boolean {
const str = String(value).trim().toLowerCase();
return ['1', 'true', 'yes', 'y'].includes(str);
}

26
src/types.ts Normal file
View File

@ -0,0 +1,26 @@
export interface ScrapePayload {
url: string;
optimizeImages: boolean;
optimizeTitles: boolean;
optimizeVariants: boolean;
needTranslate: boolean;
}
export interface ScrapeResponse {
[key: string]: unknown;
}
export type ApiResponse = import("@clawd/auth-runtime").ApiResponse;
export type Command = "session" | "scrape-url" | "scrape-payload";
export interface OutputResult {
status: 'success' | 'failed';
error: string | null;
command: string;
dryRun: boolean;
session?: unknown;
requestPayload?: ScrapePayload;
scrapeHttpStatus?: number;
scrapeBody?: unknown;
}