概要
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のいずれか1つの環境を選んでください。
PHP 8.3 (64-bit, ext-curl, JSON) · Python 3.12 + requests · cURL 8 + jq 1.7.
SHOPENTLY_SHOP_URL: /api/v1を含まないストアのHTTPSアドレスです。例を自分のストアのアドレスに置き換えてください。
SHOPENTLY_SHOP_URL=https://shop.example
SHOPENTLY_API_KEY: ストア管理画面で生成したキーです。
JSONの例をBLOG_JOB_FILEとして保存し、PNGをSHOPENTLY_IMAGE_FILEで指定してください。非公開の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 が必要です。 論理操作ごとに1つ生成し、再試行ではキー、メソッド、対象、内容を同じにしてください。