OxiRanker 문서
웹사이트를 OxiRanker 서비스에 연결하기 위한 깔끔한 문서 센터입니다. WordPress 연동 또는 API 연동으로 시작한 뒤, 가이드를 step by step으로 따라가세요.
API 서비스
커스텀 웹사이트, 커스텀 CMS 플랫폼, Next.js, Laravel, Node.js, PHP 또는 Python backend를 위해 Export API와 Webhook을 사용하세요.
OxiRanker는 destination website를 위한 permanent hosting이 아닙니다
OxiRanker는 destination website의 content, images 또는 exported files에 대해 permanent hosting 보장을 제공하지 않습니다. OxiRanker는 user-content hosting service가 아니며 generated content, cover images, Open Graph images 또는 output files는 OxiRanker infrastructure에 90일 넘게 남아 있지 않을 수 있습니다.
따라서 output을 받은 후 destination website는 final article, images, SEO data, Open Graph data 및 Schema data를 자체 database, server, storage 또는 CDN에 저장해야 하며 public display를 위해 OxiRanker URLs에 의존해서는 안 됩니다.
연결 교육 영상
WordPress를 연결하거나 API 연동을 구현하기 전에 이 영상을 시각적 walkthrough로 사용하세요.
output flow를 step by step으로 구현하세요
먼저 Export Token과 Webhook Secret을 backend에 저장한 다음 Webhook receiver, database, UPSERT logic, Image Mirroring 및 daily sync를 완료하세요.
OxiRanker에 가입하고 domain 만들기
먼저 OxiRanker에 가입하고 account에 sign in한 다음 Domains 섹션에서 Add Domain을 클릭하세요.
올바른 domain 형식
Domain name 필드에는 root domain만 입력하세요. 주소에는 http, https 또는 www가 포함되면 안 됩니다.
example.com잘못된 형식
domain 필드에 이러한 형식을 입력하지 마세요.
https://example.com
http://example.com
www.example.comdomain 소유권 확인
이 step에서는 domain이 본인 소유이거나 관리 access가 있음을 증명해야 합니다.
DNS TXT
권장domain DNS에 TXT record를 추가하세요. Cloudflare의 경우 일반적으로 가장 간단한 방법입니다.
DNS Name:
_yourdomain-verification.example.com
TXT Value:
acb1****29803HTTP File
OxiRanker가 확인할 수 있도록 웹사이트의 .well-known path에 text file을 배치하세요.
File URL:
https://example.com/.well-known/yourdomain-verification.txt
File Content:
220daaa37****42730f27Meta Tag
웹사이트 homepage의 head section 안에 meta tag를 배치하세요.
<meta name="yourdomain-verification" content="190208d****d275b65" />첫 domain 확인 후 무료 token 받기
첫 domain을 verify한 후 OxiRanker는 initial testing을 위해 account에 1000 free tokens를 추가합니다.
언어와 Blog URL 설정
Blog URLs 섹션에서 웹사이트가 single-language인지 multilingual인지, generated articles에 사용할 languages를 정의하세요.
언어 선택
OxiRanker는 24 languages를 지원합니다. 웹사이트 구조에 따라 하나 또는 여러 languages를 선택할 수 있습니다.
Blog URL 입력
각 language에 대해 해당 language의 blog URL을 입력하세요. 웹사이트가 multilingual이면 각 language path를 별도로 입력하세요.
https://example.com/fa/blog
https://example.com/en/blog각 language에 keywords 추가
Keywords tab에서 각 language에 최소 3개, 최대 50개의 keywords를 추가하세요.
주요 규칙
각 language의 keywords는 동일한 language로 입력해야 합니다. article language가 English이면 keywords도 English여야 합니다.
keywords 추가 방법
각 keyword를 입력한 뒤 Enter를 눌러 추가하세요. 모든 keywords를 완료한 뒤 Save를 클릭하세요.
domain Article Profile 완료
Article Profile은 OxiRanker에게 brand가 무엇인지, 누구를 위해 작성하는지, 어떤 tone을 사용해야 하는지, 어떤 topics를 피해야 하는지 알려줍니다.
Industry
market 또는 business category를 정의합니다. 예: Dubai의 real estate consulting 및 property buying and selling.
Business description
business가 무엇을 판매하는지, 누구에게 서비스를 제공하는지, 어떻게 작동하는지, 무엇이 다른지 설명합니다.
Audience
content가 누구에게 말해야 하는지 정의합니다. 각 audience는 별도로 저장할 수 있습니다.
Tone
formal, educational, simple, technical 또는 sales-oriented 등 generated content의 style과 voice를 제어합니다.
Content goal
articles가 달성해야 할 목표를 정의합니다. 예: customers 유치, traffic 증가, users 교육.
금지된 주제
brand가 이야기해서는 안 되거나 연관되어서는 안 되는 모든 항목을 여기에 입력합니다.
Optional CTA
CTA는 article을 읽은 후 reader가 취하기를 원하는 action을 의미합니다. 어떤 text가 suitable한지 확실하지 않다면 이 field를 empty로 두는 것이 좋습니다.
내부 및 외부 링크
OxiRanker는 articles 안에 internal links와 external links를 intelligent하게 적용할 수 있습니다.
Internal links
이 option은 default로 enabled되어 있으며 enabled 상태를 유지하는 것이 좋습니다. Internal linking은 search engines가 웹사이트의 content structure를 더 잘 이해하도록 돕습니다.
첫 번째 article에서는 previous article이 아직 없기 때문에 internal links가 일반적으로 생성되지 않습니다. 두 번째 article부터 system은 internal linking을 수행할 수 있습니다.
External links
External links는 official 및 trusted sources에 추가되며 SEO authority가 의도치 않게 전달되지 않도록 nofollow로 marked됩니다.
AI로 profile quality 확인
Article Profile을 완료한 후 OxiRanker는 profile quality를 review합니다.
article summaries를 위해 Telegram 연결
profile을 성공적으로 save한 후 Telegram channel로 article summaries 전송을 enable할 수 있습니다.
bot 추가
@Oxiranker_Bot을 Telegram channel에 추가하고 Admin으로 설정하세요.
channel username 입력
channel name을 @와 함께 입력하세요. 예: @my_channel
summary delivery enable
valid channel을 등록한 후 article summary delivery를 enable하세요.
automatic article engine 설정
Automatic article generation tab에서 automatic article generation engine을 관리할 수 있습니다.
Smart topic selection
system은 keywords와 Article Profile을 사용하여 suitable, unique, publishable한 topics를 선택합니다.
Brand-aware generation
article generation 중 tone, audience, content goal 및 brand restrictions가 반영됩니다.
Scheduled publishing
Monthly capacity는 한 달 전체에 분산되어 content publishing이 더 natural하고 consistent하게 보이도록 합니다.
Monthly capacity
7 articles / month
10 articles / month
15 articles / month
30 articles / month30 articles는 거의 하루에 한 article을 의미합니다. 15 articles는 거의 이틀에 한 번을 의미합니다. 10 articles는 약 2~3일마다를 의미합니다. 7 articles는 약 3~4일마다를 의미합니다.
Article length 및 image
Article length는 Short, Standard 또는 Long일 수 있습니다. Standard는 대부분의 웹사이트에 balanced choice입니다.
Article images는 enabled 또는 disabled로 설정할 수 있으며 image theme은 Light 또는 Dark로 선택할 수 있습니다.
Generate에서 첫 번째 article 만들기
domain을 준비한 뒤 Generate menu를 열고 domain을 선택한 다음 New Content 또는 New Article을 클릭하세요.
Language
각 content request에는 하나의 language만 선택됩니다.
Topic
명확한 topic을 작성하세요. Source URL은 optional입니다.
Image
image를 enable 또는 disable하고 Light 또는 Dark theme을 선택하세요.
Payment 및 generation
cost가 wallet에서 deducted되고 article generation이 시작됩니다.
Preview, SEO Report 및 JSON Output 검토
article이 generated된 후 quality review 및 publishing preparation을 위한 여러 important outputs를 사용할 수 있습니다.
Preview
article text와 generated image를 표시합니다. 이 section은 publishing 전 final quality를 review하는 데 유용합니다.
SEO Report
SEO Score, title, meta description, canonical, keywords, schema, headings, links, images 및 OpenGraph를 포함한 understandable SEO report를 표시합니다.
JSON Output
technical users를 위해 full article output을 표시합니다. 여기에는 slug, html_body, schema, image URLs, tags, category, reading time 및 created/updated timestamps가 포함됩니다.
Export API와 Webhook은 정확히 무엇을 하나요?
OxiRanker에서 article이 generated된 후 destination website는 Export API로 이를 read하거나 article이 ready되었을 때 Webhook을 통해 notified될 수 있습니다.
Method 1: Export API
destination website는 필요할 때 Export Token으로 OxiRanker에서 articles를 read할 수 있습니다. 이 method는 manual sync, daily sync, article list pages 및 full article JSON fetch에 사용됩니다.
GET https://oxiranker.com/api/v1/users/domains/1/export/articles?limit=20&offset=0
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/jsonGET https://oxiranker.com/api/v1/users/domains/1/articles/5/export/json
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/jsonMethod 2: Webhook
new article이 created되거나 article이 updated되면 OxiRanker는 destination website endpoint에 notify합니다. Webhook은 full article을 보내지 않습니다. destination website가 Export JSON API에서 full article을 fetch할 수 있도록 article identifier만 보냅니다.
https://example.com/api/oxiranker/webhook{
"event": "article.created",
"domain_id": "1",
"article_id": "5",
"request_id": "5",
"lang": "fa",
"slug": "smart-internal-links",
"occurred_at": "2026-05-20T19:35:33.000Z"
}Content storage responsibility 및 Image Mirroring
destination website는 final article과 images를 자체 infrastructure에 store해야 합니다.
destination website가 store해야 하는 항목
올바른 image path 예시
OxiRanker image URLs를 directly 및 permanently 사용하는 것은 올바르지 않습니다. image를 download하고 article URLs를 destination website의 internal URLs로 replace해야 합니다.
https://oxiranker.com/uploads/articles/3_5/cover.webphttps://example.com/uploads/site-articles/oxiranker/1/5/cover.webpExport Token 및 ready endpoints 받기
Export Token과 ready-to-use endpoints는 article page, Outputs tab 및 API Output section에서 사용할 수 있습니다.
Export Token을 받을 위치
https://oxiranker.com/en/pannel/generate/{domainId}/articles/{articleId}Ready-to-use endpoints
같은 article page의 API Output section 안에서 Ready-to-use endpoints가 prepared URLs를 표시합니다.
https://oxiranker.com/api/v1/users/domains/1/export/articles?limit=20&offset=0https://oxiranker.com/api/v1/users/domains/1/articles/1/export/jsonhttps://oxiranker.com/api/v1/users/domains/1/articles/1/export/html?canonical_mode=absolute&include_css=0Webhook Secret 및 destination endpoint 만들기
Webhook Secret도 article page, Outputs tab 및 API Output section에서 생성됩니다.
Webhook Secret은 어디에서 생성되나요?
고정 destination endpoint
OxiRanker는 destination Webhook에 대해 다음 fixed path만 허용합니다. destination website는 이 endpoint를 backend에 만들어야 합니다.
POST /api/oxiranker/webhook
https://example.com/api/oxiranker/webhook이 endpoint는 POST만 accept해야 하며 raw body를 preserve하고 timestamp와 signature를 verify하며 webhook.test에 대해 successful response를 return해야 합니다.
destination website에 필요한 .env values
OxiRanker는 destination website files 또는 .env에 access할 수 없습니다. destination website developer는 이 values를 자체 backend에 store해야 합니다.
OXIRANKER_EXPORT_API_BASE_URL=https://oxiranker.com
OXIRANKER_EXPORT_API_TOKEN=YOUR_EXPORT_TOKEN
OXIRANKER_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET
OXIRANKER_WEBHOOK_MAX_SKEW_MS=300000
OXIRANKER_SOURCE_PROVIDER=oxiranker
OXIRANKER_SOURCE_DOMAIN_IDS=1
SITE_ARTICLE_IMAGE_UPLOAD_DIR=/var/www/example.com/uploads/site-articles
SITE_ARTICLE_IMAGE_MAX_BYTES=10485760
PUBLIC_BASE_URL=https://example.com
OXIRANKER_SYNC_ENABLED=true
OXIRANKER_SYNC_HOUR=3
OXIRANKER_SYNC_MINUTE=10
OXIRANKER_SYNC_PAGE_LIMIT=20
OXIRANKER_SYNC_MAX_PAGES=50Security values
Storage values
Export API: List, JSON, HTML 및 ZIP
destination website는 Export API를 사용하여 article list 또는 각 article의 full output을 받을 수 있습니다.
domain의 articles list 받기
GET https://oxiranker.com/api/v1/users/domains/:domainId/export/articles?limit=20&offset=0
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/jsonlimit는 articles 수를 정의하며 현재 1~50 사이로 제한됩니다. offset은 pagination에 사용됩니다. lang은 optional이며 fa, en 또는 tr과 같은 specific language의 articles를 fetch하는 데 사용됩니다.
full article을 JSON으로 받기
GET https://oxiranker.com/api/v1/users/domains/:domainId/articles/:articleId/export/json
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/json이는 destination website에 가장 important한 output입니다. destination website는 full article JSON을 자체 database에 store하고 public display를 위해 자체 database에서 read해야 합니다.
ready HTML 받기
GET https://oxiranker.com/api/v1/users/domains/1/articles/5/export/html?canonical_mode=absolute&include_css=0
Authorization: Bearer YOUR_EXPORT_TOKENHTML은 ready output을 원하는 웹사이트에 적합하지만 더 professional한 approach는 JSON을 store하고 destination website 자체 template으로 render하는 것입니다.
ZIP output 받기
GET https://oxiranker.com/api/v1/users/domains/1/articles/5/export/zip
Authorization: Bearer YOUR_EXPORT_TOKENZIP output에는 article.json, meta.json, assets.json, schema.jsonld, index.html, version.txt 및 README.txt가 포함됩니다.
Webhook Signature 및 안전한 Webhook 수신
destination website는 original raw body를 사용하여 Webhook signature를 verify해야 합니다.
Webhook headers
content-type: application/json
user-agent: Oxiranker-Webhooks/1.0
x-oxiranker-event: article.created
x-oxiranker-domain-id: 1
x-oxiranker-timestamp: 2026-05-20T19:35:33.000Z
x-oxiranker-signature: sha256=...Signature formula
payload_to_sign = timestamp + "." + rawBody
signature = "sha256=" + HMAC_SHA256(webhook_secret, payload_to_sign)destination website가 JSON을 먼저 parse한 뒤 다시 stringify하면 order 또는 spacing이 변경되어 signature가 invalid가 될 수 있습니다. signature verification에는 original raw body를 사용해야 합니다.
Webhook 수신을 위한 simple Node.js code sample
이 Express sample은 raw body, timestamp, signature, webhook.test 및 article events를 처리합니다.
import express from "express";
import crypto from "crypto";
const app = express();
app.use(
express.json({
verify: (req, _res, buf) => {
(req as any).rawBody = Buffer.from(buf);
},
})
);
function getHeader(req: express.Request, name: string): string {
const value = req.headers[name.toLowerCase()];
if (Array.isArray(value)) return String(value[0] || "").trim();
return String(value || "").trim();
}
function getRawBody(req: express.Request): string {
const raw = (req as any).rawBody;
if (Buffer.isBuffer(raw)) return raw.toString("utf8");
return JSON.stringify(req.body || {});
}
function createSignature(secret: string, timestamp: string, rawBody: string): string {
const payload = `${timestamp}.${rawBody}`;
const digest = crypto
.createHmac("sha256", secret)
.update(payload, "utf8")
.digest("hex");
return `sha256=${digest}`;
}
function timingSafeEqualText(a: string, b: string): boolean {
const ab = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
function isTimestampValid(timestamp: string): boolean {
const maxSkewMs = Number(process.env.OXIRANKER_WEBHOOK_MAX_SKEW_MS || 300000);
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return false;
const diff = Math.abs(Date.now() - date.getTime());
return diff <= maxSkewMs;
}
app.post("/api/oxiranker/webhook", async (req, res) => {
const secret = String(process.env.OXIRANKER_WEBHOOK_SECRET || "").trim();
if (!secret) {
return res.status(500).json({
error: "webhook_secret_missing",
message: "Webhook secret is not configured.",
});
}
const eventHeader = getHeader(req, "x-oxiranker-event");
const domainIdHeader = getHeader(req, "x-oxiranker-domain-id");
const timestamp = getHeader(req, "x-oxiranker-timestamp");
const receivedSignature = getHeader(req, "x-oxiranker-signature");
const rawBody = getRawBody(req);
if (!eventHeader) {
return res.status(400).json({
error: "missing_webhook_event",
message: "Webhook event header is required.",
});
}
if (!domainIdHeader) {
return res.status(400).json({
error: "missing_webhook_domain_id",
message: "Webhook domain id header is required.",
});
}
if (!timestamp) {
return res.status(400).json({
error: "missing_webhook_timestamp",
message: "Webhook timestamp header is required.",
});
}
if (!receivedSignature) {
return res.status(400).json({
error: "missing_webhook_signature",
message: "Webhook signature header is required.",
});
}
if (!isTimestampValid(timestamp)) {
return res.status(401).json({
error: "invalid_webhook_timestamp",
message: "Webhook timestamp is expired or invalid.",
});
}
const expectedSignature = createSignature(secret, timestamp, rawBody);
if (!timingSafeEqualText(expectedSignature, receivedSignature)) {
return res.status(401).json({
error: "invalid_webhook_signature",
message: "Invalid webhook signature.",
});
}
const payload = req.body || {};
const event = String(payload.event || eventHeader || "").trim();
if (event === "webhook.test") {
const sourceDomainId = Number(payload.domain_id || domainIdHeader);
return res.status(200).json({
ok: true,
message: "Webhook test received successfully.",
event,
source_domain_id:
Number.isFinite(sourceDomainId) && sourceDomainId > 0 ? sourceDomainId : null,
test: true,
});
}
if (event !== "article.created" && event !== "article.updated") {
return res.status(400).json({
error: "unsupported_webhook_event",
message: "Webhook event is not supported.",
});
}
const sourceDomainId = Number(payload.domain_id || domainIdHeader);
const sourceArticleId = Number(payload.article_id);
if (!Number.isFinite(sourceDomainId) || sourceDomainId <= 0) {
return res.status(400).json({
error: "invalid_domain_id",
message: "Webhook domain id is invalid.",
});
}
if (!Number.isFinite(sourceArticleId) || sourceArticleId <= 0) {
return res.status(400).json({
error: "invalid_article_id",
message: "Webhook article id is invalid.",
});
}
// Fetch the full article from Export API and store it in the database here.
// await syncArticleFromOxiranker(sourceDomainId, sourceArticleId);
return res.status(200).json({
ok: true,
message: "Webhook processed successfully.",
event,
source_domain_id: sourceDomainId,
source_article_id: sourceArticleId,
});
});권장 destination website database
destination website는 articles를 자체 database에 store하고 unique constraint로 duplicates를 방지해야 합니다.
CREATE TABLE public.site_articles (
id BIGSERIAL PRIMARY KEY,
source_provider VARCHAR NOT NULL DEFAULT 'oxiranker',
source_domain_id BIGINT NOT NULL,
source_article_id BIGINT NOT NULL,
source_request_id BIGINT NULL,
lang VARCHAR NOT NULL,
lang_label VARCHAR NULL,
dir VARCHAR NOT NULL,
is_rtl BOOLEAN NULL,
length VARCHAR NULL,
topic_text TEXT NULL,
slug VARCHAR NOT NULL,
canonical_path VARCHAR NULL,
effective_canonical_url TEXT NULL,
title TEXT NOT NULL,
meta_description TEXT NULL,
html_body TEXT NULL,
focus_keywords JSONB NOT NULL DEFAULT '[]'::jsonb,
category VARCHAR NULL,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
reading_time_minutes INTEGER NULL,
og_title TEXT NULL,
og_description TEXT NULL,
cover_image_url TEXT NULL,
og_image_url TEXT NULL,
article_schema JSONB NOT NULL DEFAULT '{}'::jsonb,
raw_list_item JSONB NOT NULL DEFAULT '{}'::jsonb,
raw_detail_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
source_created_at TIMESTAMPTZ NULL,
source_updated_at TIMESTAMPTZ NULL,
sync_status VARCHAR NOT NULL DEFAULT 'synced',
last_synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_sync_error TEXT NULL,
publish_status VARCHAR NOT NULL DEFAULT 'draft',
published_at TIMESTAMPTZ NULL,
deleted_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
source_cover_image_url TEXT NULL,
source_og_image_url TEXT NULL,
image_mirror_status VARCHAR NOT NULL DEFAULT 'pending',
image_mirror_error TEXT NULL,
image_mirrored_at TIMESTAMPTZ NULL,
CONSTRAINT site_articles_dir_check
CHECK (dir IN ('ltr', 'rtl')),
CONSTRAINT site_articles_reading_time_check
CHECK (reading_time_minutes IS NULL OR reading_time_minutes >= 0),
CONSTRAINT site_articles_sync_status_check
CHECK (sync_status IN ('pending', 'syncing', 'synced', 'failed')),
CONSTRAINT site_articles_publish_status_check
CHECK (publish_status IN ('draft', 'published', 'archived')),
CONSTRAINT site_articles_image_mirror_status_check
CHECK (image_mirror_status IN ('pending', 'not_needed', 'mirrored', 'failed')),
CONSTRAINT site_articles_source_unique
UNIQUE (source_provider, source_domain_id, source_article_id)
);CREATE INDEX site_articles_source_lookup_idx
ON public.site_articles (source_provider, source_domain_id, source_article_id);
CREATE INDEX site_articles_source_updated_at_idx
ON public.site_articles (source_updated_at DESC);
CREATE INDEX site_articles_slug_idx
ON public.site_articles (slug)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX site_articles_lang_slug_active_unique
ON public.site_articles (lang, slug)
WHERE deleted_at IS NULL;
CREATE INDEX site_articles_lang_publish_idx
ON public.site_articles (lang, publish_status)
WHERE deleted_at IS NULL;
CREATE INDEX site_articles_published_at_idx
ON public.site_articles (published_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX site_articles_focus_keywords_gin_idx
ON public.site_articles USING gin (focus_keywords);
CREATE INDEX site_articles_tags_gin_idx
ON public.site_articles USING gin (tags);
CREATE INDEX site_articles_article_schema_gin_idx
ON public.site_articles USING gin (article_schema);UPSERT로 duplicates 방지
article은 각 Webhook마다 다시 insert되면 안 됩니다. 이미 존재하는 경우 update되어야 합니다.
INSERT INTO public.site_articles (
source_provider,
source_domain_id,
source_article_id,
source_request_id,
lang,
dir,
slug,
title,
meta_description,
html_body,
focus_keywords,
category,
tags,
reading_time_minutes,
cover_image_url,
og_image_url,
article_schema,
source_created_at,
source_updated_at,
sync_status,
last_synced_at,
publish_status,
published_at,
created_at,
updated_at
)
VALUES (
'oxiranker',
1,
5,
5,
'fa',
'rtl',
'smart-internal-links',
'Article title',
'Meta description',
'<p>HTML body</p>',
'[]'::jsonb,
'SEO',
'[]'::jsonb,
7,
'https://example.com/uploads/site-articles/oxiranker/1/5/cover.webp',
'https://example.com/uploads/site-articles/oxiranker/1/5/cover.webp',
'{}'::jsonb,
'2026-05-20T19:33:23.618Z',
'2026-05-20T19:34:09.629Z',
'synced',
now(),
'published',
now(),
now(),
now()
)
ON CONFLICT (source_provider, source_domain_id, source_article_id)
DO UPDATE SET
source_request_id = EXCLUDED.source_request_id,
lang = EXCLUDED.lang,
dir = EXCLUDED.dir,
slug = EXCLUDED.slug,
title = EXCLUDED.title,
meta_description = EXCLUDED.meta_description,
html_body = EXCLUDED.html_body,
focus_keywords = EXCLUDED.focus_keywords,
category = EXCLUDED.category,
tags = EXCLUDED.tags,
reading_time_minutes = EXCLUDED.reading_time_minutes,
cover_image_url = EXCLUDED.cover_image_url,
og_image_url = EXCLUDED.og_image_url,
article_schema = EXCLUDED.article_schema,
source_created_at = EXCLUDED.source_created_at,
source_updated_at = EXCLUDED.source_updated_at,
sync_status = 'synced',
last_synced_at = now(),
last_sync_error = NULL,
publish_status = CASE
WHEN public.site_articles.publish_status = 'archived'
THEN public.site_articles.publish_status
ELSE 'published'
END,
published_at = COALESCE(public.site_articles.published_at, EXCLUDED.published_at, now()),
deleted_at = NULL,
updated_at = now();Webhook 이후 full article fetch 및 sync
Webhook이 도착하면 destination website는 Export API에서 full article을 fetch하고 images를 mirror한 뒤 article을 UPSERT해야 합니다.
async function fetchOxirankerArticleDetail(args: {
baseUrl: string;
token: string;
domainId: number;
articleId: number;
}) {
const url = `${args.baseUrl.replace(/\/+$/, "")}/api/v1/users/domains/${args.domainId}/articles/${args.articleId}/export/json`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${args.token}`,
Accept: "application/json",
"User-Agent": "My-Site-Oxiranker-Sync/1.0",
},
});
const bodyText = await response.text();
if (response.status < 200 || response.status > 299) {
throw new Error(
`Export API request failed with HTTP status ${response.status}. Response: ${bodyText.slice(0, 1000)}`
);
}
try {
return JSON.parse(bodyText);
} catch {
throw new Error("Export API returned invalid JSON.");
}
}async function syncArticleFromWebhook(payload: any) {
const sourceDomainId = Number(payload.domain_id);
const sourceArticleId = Number(payload.article_id);
if (!Number.isFinite(sourceDomainId) || sourceDomainId <= 0) {
throw new Error("Webhook domain id is invalid.");
}
if (!Number.isFinite(sourceArticleId) || sourceArticleId <= 0) {
throw new Error("Webhook article id is invalid.");
}
const article = await fetchOxirankerArticleDetail({
baseUrl: process.env.OXIRANKER_EXPORT_API_BASE_URL!,
token: process.env.OXIRANKER_EXPORT_API_TOKEN!,
domainId: sourceDomainId,
articleId: sourceArticleId,
});
const mirroredImages = await mirrorArticleImages(article);
const finalHtmlBody = replaceImageUrls(article.html_body, mirroredImages);
const finalArticleSchema = replaceImageUrlsInJson(article.article_schema, mirroredImages);
await upsertSiteArticle({
source_provider: process.env.OXIRANKER_SOURCE_PROVIDER || "oxiranker",
source_domain_id: sourceDomainId,
source_article_id: sourceArticleId,
source_request_id: article.request_id,
lang: article.lang,
dir: article.dir,
slug: article.slug,
title: article.title,
meta_description: article.meta_description,
html_body: finalHtmlBody,
focus_keywords: article.focus_keywords,
category: article.category,
tags: article.tags,
reading_time_minutes: article.reading_time_minutes,
cover_image_url: mirroredImages.cover_image_url,
og_image_url: mirroredImages.og_image_url,
article_schema: finalArticleSchema,
raw_detail_payload: article,
source_created_at: article.created_at,
source_updated_at: article.updated_at,
});
}Image Mirroring 및 image storage rules
destination website는 images를 download하고 자체 infrastructure에 store하며 article URLs를 replace해야 합니다.
Image Mirroring rules
권장 storage path
/uploads/site-articles/oxiranker/1/5/cover.webp
/uploads/site-articles/oxiranker/1/5/og.webphttps://example.com/uploads/site-articles/oxiranker/1/5/cover.webp신뢰성을 위한 daily sync
Webhook만으로는 충분하지 않습니다. destination website는 missed articles를 recover하기 위한 daily sync도 가져야 합니다.
daily sync가 필요한 이유
Daily sync algorithm
destination website에 articles 표시
articles를 site_articles에 store한 후 destination website는 각 public display를 위해 OxiRanker에 연결하면 안 됩니다. 자체 database에서 read해야 합니다.
Blog list page
SELECT
id,
lang,
lang_label,
dir,
is_rtl,
slug,
title,
meta_description,
category,
tags,
reading_time_minutes,
cover_image_url,
og_image_url,
source_created_at,
published_at,
created_at
FROM public.site_articles
WHERE source_provider = 'oxiranker'
AND deleted_at IS NULL
AND sync_status = 'synced'
AND publish_status = 'published'
ORDER BY COALESCE(source_created_at, published_at, created_at) DESC, id DESC
LIMIT 20 OFFSET 0;Article detail page
SELECT *
FROM public.site_articles
WHERE source_provider = 'oxiranker'
AND lang = 'fa'
AND slug = 'smart-internal-links'
AND deleted_at IS NULL
AND sync_status = 'synced'
AND publish_status = 'published'
LIMIT 1;SEO, Schema, RTL 및 HTML rendering
destination website는 자체 database에서 SEO data를 read하고 article page에서 render해야 합니다.
SEO fields
HTML rendering
<div
className="article-content"
dangerouslySetInnerHTML={{ __html: article.html_body }}
/>destination website에 strict security policy가 있는 경우 save 전 또는 display 전에 HTML을 sanitize할 수 있습니다.
RTL 및 LTR
각 article에는 lang, dir 및 is_rtl fields가 있습니다. dir이 rtl이면 article page는 right-to-left로 render되어야 합니다.
<article dir="rtl">
...
</article>일반적인 errors와 의미
destination website errors는 troubleshooting을 빠르게 할 수 있도록 English, clear 및 understandable해야 합니다.
Export API errors
Webhook errors
최종 API 및 Webhook testing checklist
implementation 후 이 tests는 connection을 beginning부터 end까지 verify합니다.
Export Token 및 JSON test
GET https://oxiranker.com/api/v1/users/domains/1/export/articles?limit=2&offset=0
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/jsonGET https://oxiranker.com/api/v1/users/domains/1/articles/5/export/json
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/jsonWebhook test
destination Webhook을 Postman으로 signature 없이 call하면 security error를 받아야 합니다. 이는 endpoint가 active이고 security를 check한다는 의미입니다.
{
"error": "missing_webhook_signature",
"message": "Webhook signature header is required."
}그런 다음 OxiRanker 안에서 Test Webhook을 클릭하세요. receiver가 webhook.test를 support하면 response_status는 200이어야 합니다.
올바른 implementation을 위한 important rules
system이 secure, stable 및 maintainable 상태를 유지하도록 API integration에서 이 rules를 따라야 합니다.
Complete API flow summary
이 steps를 완료하면 custom website는 OxiRanker에서 generated된 articles를 안전하게 receive, store, mirror 및 publish할 수 있습니다.