概览
使用 HTML 文章、照片、作者信息和搜索元数据经营店铺博客。保存草稿、准备翻译,并立即发布或安排在指定时区的所选时间发布。在面板中管理评论和审核;外部生成工具可以通过已授予权限的 API 进行操作。
- 身份验证
- 新建作者
- 上传文章照片
- 新建文章
- 编辑文章
- 安排发布
- 阅读文章和预览草稿
所需权限
BASIC · api.enabled · addons.use(shopently.blog)
- addon.shopently.blog:author_create — 创建文章作者
- addon.shopently.blog:media_upload — 上传文章照片
- addon.shopently.blog:post_create — 创建文章草稿
- addon.shopently.blog:post_update — 编辑文章草稿和翻译
- addon.shopently.blog:post_publish — 发布、安排和归档文章
- addon.shopently.blog:post_read — 阅读文章和预览草稿
所需的已安装模块: shopently.blog · 模块契约版本: 1.
请求
准备好访问商店管理面板的权限。选择一种环境:cURL、PHP 或 Python。
PHP 8.3 (64-bit, ext-curl, JSON) · Python 3.12 + requests · cURL 8 + jq 1.7.
SHOPENTLY_SHOP_URL: 商店的 HTTPS 地址,不含 /api/v1。请将示例替换为您自己的地址。
SHOPENTLY_SHOP_URL=https://shop.example
SHOPENTLY_API_KEY: 在商店管理面板中生成的密钥。
将 JSON 示例保存为 BLOG_JOB_FILE,通过 SHOPENTLY_IMAGE_FILE 指定 PNG,并选择私有的 BLOG_STATE_DIR。重试时保留原始文件和目录;新文章应使用新的 job_id 和目录。
Linux/POSIX · flock · sha256sum · sync · PNG ≤ 5 MiB · 8 MP · HTML ≤ 50000 · JSON ≤ 256 KiB.
完整示例
cURL
#!/bin/sh
# Linux/POSIX shell, curl 8+, jq 1.7, flock, sha256sum, sync.
# Retain the immutable job, PNG and private state directory for retries.
set -eu
umask 077
: "${SHOPENTLY_SHOP_URL:?}" "${SHOPENTLY_API_KEY:?}" "${BLOG_JOB_FILE:?}"
: "${SHOPENTLY_IMAGE_FILE:?}" "${BLOG_STATE_DIR:?}"
base=${SHOPENTLY_SHOP_URL%/}
case "$base" in https://*) ;; *) echo 'Use an HTTPS origin' >&2; exit 1;; esac
authority=${base#https://}
case "$authority" in ''|*/*|*\?*|*\#*|*@*) echo 'Use an HTTPS origin' >&2; exit 1;; esac
state=$BLOG_STATE_DIR
mkdir -p "$state"
exec 9>"$state/.lock"
flock -n 9 || { echo 'This job is already running' >&2; exit 1; }
work=$(mktemp -d "$state/.run.XXXXXX")
trap 'rm -rf "$work"' EXIT HUP INT TERM
cp "$BLOG_JOB_FILE" "$work/job.json"
cp "$SHOPENTLY_IMAGE_FILE" "$work/article.png"
[ "$(wc -c <"$work/article.png")" -le 5242880 ] || { echo 'Image exceeds 5 MiB' >&2; exit 1; }
[ "$(od -An -N8 -tx1 "$work/article.png" | tr -d ' \n')" = 89504e470d0a1a0a ] || { echo 'Use a PNG image' >&2; exit 1; }
identity=$(jq -er '.job_id | select(type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$"))' "$work/job.json")
digest=$({ printf '%s\n' "$base"; cat "$work/job.json"; printf '\n'; cat "$work/article.png"; } | sha256sum | cut -d' ' -f1)
jq -nc --arg id "$identity" --arg sha "$digest" '{job_id:$id,sha256:$sha}' >"$work/marker.json"
if [ -f "$state/job.json" ]; then
jq -e --slurpfile intended "$work/marker.json" '. == $intended[0]' "$state/job.json" >/dev/null ||
{ echo 'A retry must use the original origin, job and PNG bytes' >&2; exit 1; }
else
sync -f "$work/marker.json"
mv "$work/marker.json" "$state/job.json"
sync -f "$state"
fi
# The token response stays in memory and is never included in persisted job results.
raw_token=$(curl --disable --globoff --fail-with-body --silent --show-error \
--proto '=https' --connect-timeout 5 --max-time 30 --request POST \
--header "Authorization: ApiKey $SHOPENTLY_API_KEY" --url "$base/api/v1/auth/token")
token=$(printf '%s' "$raw_token" | jq -er '.data.access_token | select(type == "string" and length > 0)')
unset raw_token
prefix=/api/v1/addons/shopently.blog
encode() { printf '%s' "$1" | jq -sRr @uri; }
api() {
method=$1; path=$2; body_file=${3-}; key=${4-}; upload=${5-false}
set -- --disable --globoff --silent --show-error --proto '=https' --connect-timeout 5 --max-time 30 \
--request "$method" --header "Authorization: Bearer $token" \
--dump-header "$work/headers" --output "$work/body" --write-out '%{http_code}'
if [ -n "$key" ]; then set -- "$@" --header "Idempotency-Key: $key"; fi
if [ -n "$body_file" ]; then set -- "$@" --header 'Content-Type: application/json' --data-binary "@$body_file"; fi
if [ "$upload" = true ]; then
set -- "$@" --form "file=@\"$work/article.png\";filename=article.png;type=image/png"
fi
status=$(curl "$@" --url "$base$path") || return 1
case "$status" in
2??) jq -ec '.data' "$work/body" ;;
*) printf 'HTTP %s; inspect Retry-After before retrying this unchanged job\n' "$status" >&2; return 1 ;;
esac
}
step() {
name=$1; method=$2; path=$3; body_file=${4-}; upload=${5-false}
if [ -f "$state/$name.json" ]; then cat "$state/$name.json"; return; fi
api "$method" "$path" "$body_file" "$identity-$name" "$upload" >"$work/result.json"
sync -f "$work/result.json"
mv "$work/result.json" "$state/$name.json"
sync -f "$state"
cat "$state/$name.json"
}
jq -e '.author' "$work/job.json" >"$work/author-body.json"
step author POST "$prefix/authors" "$work/author-body.json" >"$work/author.json"
step media POST "$prefix/media" '' true >"$work/media.json"
jq -e --slurpfile author "$work/author.json" --slurpfile media "$work/media.json" \
'.post + {author_id:$author[0].id,cover_media_id:$media[0].id}' "$work/job.json" >"$work/post-body.json"
step post POST "$prefix/posts" "$work/post-body.json" >"$work/post.json"
post_path="$prefix/posts/$(encode "$(jq -er .id "$work/post.json")")"
jq -e --slurpfile post "$work/post.json" '.update + {locale:.post.locale,expected_version:$post[0].version}' \
"$work/job.json" >"$work/edit-body.json"
step edit PATCH "$post_path" "$work/edit-body.json" >"$work/edit.json"
jq -e --slurpfile edit "$work/edit.json" '.schedule + {expected_version:$edit[0].version,revision_id:$edit[0].revision_id}' \
"$work/job.json" >"$work/schedule-body.json"
step schedule POST "$post_path/schedule" "$work/schedule-body.json" >/dev/null
wait_seconds=${BLOG_WAIT_SECONDS-180}
case "$wait_seconds" in ''|*[!0-9]*) echo 'Invalid BLOG_WAIT_SECONDS' >&2; exit 1;; esac
[ "$wait_seconds" -le 3600 ] || { echo 'BLOG_WAIT_SECONDS exceeds 3600' >&2; exit 1; }
deadline=$(($(date +%s) + wait_seconds))
locale=$(jq -er .post.locale "$work/job.json")
revision=$(jq -er .revision_id "$work/edit.json")
while :; do
current=$(api GET "$post_path?locale=$(encode "$locale")")
if [ "$wait_seconds" -eq 0 ] || printf '%s' "$current" | jq -e --arg r "$revision" '.published_revision_id == $r' >/dev/null; then
printf '%s\n' "$current" | jq .
break
fi
if printf '%s' "$current" | jq -e '.schedule_state == "requires_confirmation"' >/dev/null; then
echo 'Publication requires a new confirmation in the store' >&2; exit 1
fi
[ "$(date +%s)" -lt "$deadline" ] || { echo 'Not published yet; retain this job and state for a later read' >&2; exit 1; }
sleep 5
done
PHP
<?php
declare(strict_types=1);
// PHP 8.3+, 64-bit, ext-curl. Retain the job, PNG and private state directory.
function env(string $name): string {
$value = getenv($name);
if ($value === false || $value === '' || strpbrk($value, "\r\n") !== false) {
throw new RuntimeException('Invalid environment variable: ' . $name);
}
return $value;
}
$base = rtrim(env('SHOPENTLY_SHOP_URL'), '/');
$origin = parse_url($base);
if (!$origin || ($origin['scheme'] ?? '') !== 'https' || empty($origin['host']) ||
isset($origin['user']) || isset($origin['pass']) || isset($origin['path']) ||
isset($origin['query']) || isset($origin['fragment']) || PHP_INT_SIZE !== 8) {
throw new RuntimeException('Use 64-bit PHP and an HTTPS origin');
}
$rawJob = file_get_contents(env('BLOG_JOB_FILE'));
$rawImage = file_get_contents(env('SHOPENTLY_IMAGE_FILE'));
if ($rawJob === false || $rawImage === false || strlen($rawImage) > 5242880 ||
!str_starts_with($rawImage, "\x89PNG\r\n\x1a\n")) {
throw new RuntimeException('Use a readable job and PNG image up to 5 MiB');
}
$job = json_decode($rawJob, true, 512, JSON_THROW_ON_ERROR);
$identity = $job['job_id'];
if (!is_string($identity) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/D', $identity)) {
throw new RuntimeException('job_id must identify one immutable generation job');
}
$digest = hash('sha256', $base . "\n" . $rawJob . "\n" . $rawImage);
$state = env('BLOG_STATE_DIR');
umask(0077);
if (!is_dir($state) && !mkdir($state, 0700, true)) throw new RuntimeException('Cannot create state directory');
$lock = fopen($state . '/.lock', 'ab');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) throw new RuntimeException('This job is already running');
function persist(string $name, array $value): void {
global $state;
$temporary = $state . '/' . $name . '.tmp';
$handle = fopen($temporary, 'xb');
if (!$handle) throw new RuntimeException('Cannot create state temporary');
try {
$raw = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
if (fwrite($handle, $raw) !== strlen($raw) || !fflush($handle) || !fsync($handle)) {
throw new RuntimeException('Cannot persist complete state');
}
} finally { fclose($handle); }
if (!rename($temporary, $state . '/' . $name)) throw new RuntimeException('Cannot commit state');
}
try {
foreach (['job', 'author', 'media', 'post', 'edit', 'schedule'] as $name) {
$temporary = $state . '/' . $name . '.json.tmp';
if (is_file($temporary)) unlink($temporary);
}
$marker = ['job_id' => $identity, 'sha256' => $digest];
if (is_file($state . '/job.json')) {
if (json_decode(file_get_contents($state . '/job.json'), true, 512, JSON_THROW_ON_ERROR) !== $marker) {
throw new RuntimeException('A retry must use the original origin, job and PNG bytes');
}
} else { persist('job.json', $marker); }
function api(string $method, string $path, ?array $body = null, ?string $key = null, bool $upload = false, bool $apiKey = false): array {
global $base, $token, $rawImage;
$headers = ['Authorization: ' . ($apiKey ? 'ApiKey ' . env('SHOPENTLY_API_KEY') : 'Bearer ' . $token)];
if ($key !== null) $headers[] = 'Idempotency-Key: ' . $key;
if ($body !== null) $headers[] = 'Content-Type: application/json';
$responseHeaders = [];
$h = curl_init($base . $path);
curl_setopt_array($h, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 30,
CURLOPT_HEADERFUNCTION => static function ($h, string $line) use (&$responseHeaders): int {
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return strlen($line);
},
]);
if ($body !== null) curl_setopt($h, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
if ($upload) curl_setopt($h, CURLOPT_POSTFIELDS, ['file' => new CURLStringFile($rawImage, 'article.png', 'image/png')]);
$raw = curl_exec($h);
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
$error = curl_error($h);
curl_close($h);
if ($raw === false) throw new RuntimeException('Transport error: ' . $error);
if ($status < 200 || $status >= 300) throw new RuntimeException('HTTP ' . $status .
'; request_id=' . ($responseHeaders['x-correlation-id'] ?? '') .
'; retry_after=' . ($responseHeaders['retry-after'] ?? ''));
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING)['data'];
}
// Obtain a new short-lived token on each run; never persist credentials.
$token = api('POST', '/api/v1/auth/token', apiKey: true)['access_token'];
$prefix = '/api/v1/addons/shopently.blog';
function step(string $name, string $method, string $path, ?array $body = null, bool $upload = false): array {
global $identity, $state;
$target = $state . '/' . $name . '.json';
if (is_file($target)) return json_decode(file_get_contents($target), true, 512, JSON_THROW_ON_ERROR);
$result = api($method, $path, $body, $identity . '-' . $name, $upload);
persist($name . '.json', $result);
return $result;
}
$author = step('author', 'POST', $prefix . '/authors', $job['author']);
$media = step('media', 'POST', $prefix . '/media', upload: true);
$post = step('post', 'POST', $prefix . '/posts', array_replace($job['post'], [
'author_id' => $author['id'], 'cover_media_id' => $media['id'],
]));
$path = $prefix . '/posts/' . rawurlencode($post['id']);
$edit = step('edit', 'PATCH', $path, array_replace($job['update'], [
'locale' => $job['post']['locale'], 'expected_version' => $post['version'],
]));
step('schedule', 'POST', $path . '/schedule', array_replace($job['schedule'], [
'expected_version' => $edit['version'], 'revision_id' => $edit['revision_id'],
]));
$wait = filter_var(getenv('BLOG_WAIT_SECONDS') === false ? '180' : getenv('BLOG_WAIT_SECONDS'),
FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 3600]]);
if ($wait === false) throw new RuntimeException('BLOG_WAIT_SECONDS must be between 0 and 3600');
$deadline = hrtime(true) / 1e9 + $wait;
while (true) {
$current = api('GET', $path . '?locale=' . rawurlencode($job['post']['locale']));
if ($current['published_revision_id'] === $edit['revision_id'] || $wait === 0) {
echo json_encode($current, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
break;
}
if ($current['schedule_state'] === 'requires_confirmation') {
throw new RuntimeException('Publication requires a new confirmation in the store');
}
if (hrtime(true) / 1e9 >= $deadline) throw new RuntimeException('Not published yet; retain this job and state for a later read');
usleep((int) (min(5, max(0, $deadline - hrtime(true) / 1e9)) * 1000000));
}
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
Python
"""Python 3.12, requests, POSIX. Keep the job, PNG and state directory for retries."""
import fcntl
import hashlib
import json
import os
import re
import time
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import quote, urlsplit
import requests
def env(name):
value = os.environ[name]
if not value or "\r" in value or "\n" in value:
raise ValueError("Invalid environment variable: " + name)
return value
base = env("SHOPENTLY_SHOP_URL").rstrip("/")
origin = urlsplit(base)
if (
origin.scheme != "https"
or not origin.hostname
or origin.username
or origin.password
or origin.path
or origin.query
or origin.fragment
):
raise ValueError("SHOPENTLY_SHOP_URL must be an HTTPS origin")
job_raw = Path(env("BLOG_JOB_FILE")).read_bytes()
image_path = Path(env("SHOPENTLY_IMAGE_FILE"))
image_raw = image_path.read_bytes()
if len(image_raw) > 5242880 or not image_raw.startswith(b"\x89PNG\r\n\x1a\n"):
raise ValueError("Use a PNG image up to 5 MiB")
job = json.loads(job_raw)
identity = job["job_id"]
if not isinstance(identity, str) or not re.fullmatch(
r"[A-Za-z0-9][A-Za-z0-9._:-]{0,79}", identity
):
raise ValueError("job_id must identify one immutable generation job")
digest = hashlib.sha256(base.encode() + b"\n" + job_raw + b"\n" + image_raw).hexdigest()
state = Path(env("BLOG_STATE_DIR"))
state.mkdir(mode=0o700, parents=True, exist_ok=True)
os.umask(0o077)
def persist(name, value):
temporary = state / (name + ".tmp")
try:
with temporary.open("x") as output:
json.dump(value, output, ensure_ascii=False, allow_nan=False)
output.flush()
os.fsync(output.fileno())
temporary.replace(state / name)
descriptor = os.open(state, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
finally:
temporary.unlink(missing_ok=True)
@contextmanager
def lock():
with (state / ".lock").open("a") as handle:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
# A crash can leave only our atomic-write temporary, never a completed result.
for name in (
"job.json",
"author.json",
"media.json",
"post.json",
"edit.json",
"schedule.json",
):
(state / (name + ".tmp")).unlink(missing_ok=True)
yield
with lock(), requests.Session() as session:
marker = state / "job.json"
if marker.exists():
if json.loads(marker.read_text()) != {"job_id": identity, "sha256": digest}:
raise ValueError("A retry must use the original origin, job and PNG bytes")
else:
persist("job.json", {"job_id": identity, "sha256": digest})
def api(method, path, *, body=None, key=None, upload=False, api_key=False):
credential = env("SHOPENTLY_API_KEY") if api_key else token
headers = {"Authorization": ("ApiKey " if api_key else "Bearer ") + credential}
if key:
headers["Idempotency-Key"] = key
arguments = {"json": body} if body is not None else {}
if upload:
arguments["files"] = {"file": ("article.png", image_raw, "image/png")}
response = session.request(
method,
base + path,
headers=headers,
timeout=(5, 30),
allow_redirects=False,
**arguments,
)
if not 200 <= response.status_code < 300:
raise RuntimeError(
"HTTP "
+ str(response.status_code)
+ "; request_id="
+ response.headers.get("X-Correlation-ID", "")
+ "; retry_after="
+ response.headers.get("Retry-After", "")
)
return response.json()["data"]
# A restart obtains a fresh short-lived token. Tokens never enter the state files.
token = api("POST", "/api/v1/auth/token", api_key=True)["access_token"]
prefix = "/api/v1/addons/shopently.blog"
def step(name, method, path, body=None, upload=False):
target = state / (name + ".json")
if target.exists():
return json.loads(target.read_text())
result = api(method, path, body=body, key=identity + "-" + name, upload=upload)
persist(name + ".json", result)
return result
author = step("author", "POST", prefix + "/authors", job["author"])
media = step("media", "POST", prefix + "/media", upload=True)
post = step(
"post",
"POST",
prefix + "/posts",
{
**job["post"],
"author_id": author["id"],
"cover_media_id": media["id"],
},
)
path = prefix + "/posts/" + quote(post["id"], safe="")
edit = step(
"edit",
"PATCH",
path,
{
**job["update"],
"locale": job["post"]["locale"],
"expected_version": post["version"],
},
)
step(
"schedule",
"POST",
path + "/schedule",
{
**job["schedule"],
"expected_version": edit["version"],
"revision_id": edit["revision_id"],
},
)
wait = int(os.environ.get("BLOG_WAIT_SECONDS", "180"))
if not 0 <= wait <= 3600:
raise ValueError("BLOG_WAIT_SECONDS must be between 0 and 3600")
deadline = time.monotonic() + wait
while True:
current = api("GET", path + "?locale=" + quote(job["post"]["locale"], safe=""))
if current["published_revision_id"] == edit["revision_id"] or wait == 0:
print(json.dumps(current, ensure_ascii=False, indent=2))
break
if current["schedule_state"] == "requires_confirmation":
raise RuntimeError("Publication requires a new confirmation in the store")
if time.monotonic() >= deadline:
raise TimeoutError(
"Not published yet; retain this job and state for a later read"
)
time.sleep(min(5, max(0, deadline - time.monotonic())))
JSON
{
"job_id": "blog-guide-20261007-001",
"author": {
"display_name": "Store editorial team",
"biography": "Practical guides prepared by our editorial team.",
"is_active": true
},
"post": {
"locale": "pl",
"title": "Jak wybrać produkt",
"slug": "jak-wybrac-produkt",
"excerpt": "Praktyczny poradnik zakupowy.",
"content_html": "<h2>Porównaj właściwości</h2><p>Sprawdź opis i wybierz odpowiedni wariant.</p>",
"meta_title": "Jak wybrać produkt",
"meta_description": "Wskazówki dotyczące wyboru produktu i porównania wariantów.",
"meta_keywords": [
"poradnik",
"wybór"
],
"cover_alt": "Ilustracja poradnika",
"cover_caption": "Zdjęcie redakcyjne",
"comments_mode": "open"
},
"update": {
"excerpt": "Praktyczny poradnik: porównanie właściwości i wariantów."
},
"schedule": {
"publish_at": "2027-01-15T10:00:00+01:00",
"timezone": "Europe/Warsaw"
}
}
发布设置
将 publish_at 改为带 UTC 偏移量的未来时间,并在 timezone 中设置对应的 IANA 时区。夏令时切换时无效或有歧义的时间会被拒绝。BLOG_WAIT_SECONDS 控制发布等待时间,0 表示读取当前状态。权限被撤销后需要重新确认发布。
重试策略
每次资源写入都要求 Idempotency-Key。 每个逻辑操作生成一个密钥;重试同一操作时保持密钥、方法、目标和请求内容一致。