c7bad83496
Add WordPress as an enterprise-site CMS type alongside pbootcms, and let schedule tasks auto-publish generated articles to enterprise sites. - WordPress connection/publisher service and tests; register wordpress media platform and relax cms_type CHECK constraint - New publish_enterprise_site_targets JSONB column on schedule_tasks with array type constraint; thread targets through scheduler dispatch, prompt/KOL generation, and worker - Admin UI: enterprise-site targets in generate/schedule/publish flows, KOL package form, MediaView, and i18n strings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
427 lines
12 KiB
Go
427 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const pbootCMSPlatformID = "pbootcms"
|
|
|
|
type cmsPublisher interface {
|
|
Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error)
|
|
Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error)
|
|
Publish(ctx context.Context, credential enterpriseSiteCredential, req cmsPublishRequest) (*cmsPublishResult, map[string]any, error)
|
|
}
|
|
|
|
type pbootCMSPublisher struct {
|
|
client *http.Client
|
|
}
|
|
|
|
type enterpriseSiteCredential struct {
|
|
SiteURL string
|
|
AppID string
|
|
Secret string
|
|
DefaultAcode string
|
|
DefaultMcode string
|
|
DefaultScode string
|
|
}
|
|
|
|
type cmsPublishRequest struct {
|
|
ArticleID int64
|
|
Title string
|
|
ContentHTML string
|
|
CategoryID string
|
|
Keywords string
|
|
Description string
|
|
Author string
|
|
Source string
|
|
CoverURL string
|
|
PublishType string
|
|
}
|
|
|
|
type cmsPublishResult struct {
|
|
RemoteID string
|
|
URL string
|
|
Status string
|
|
}
|
|
|
|
type pbootCMSResponse struct {
|
|
Code int `json:"code"`
|
|
Data json.RawMessage `json:"data"`
|
|
Msg string `json:"msg"`
|
|
}
|
|
|
|
type pbootCMSRequestError struct {
|
|
message string
|
|
routeUnavailable bool
|
|
}
|
|
|
|
type unsupportedCMSPublisher struct {
|
|
cmsType string
|
|
}
|
|
|
|
func (err *pbootCMSRequestError) Error() string {
|
|
return err.message
|
|
}
|
|
|
|
func (p unsupportedCMSPublisher) Ping(context.Context, enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) {
|
|
return nil, nil, p.err()
|
|
}
|
|
|
|
func (p unsupportedCMSPublisher) Categories(context.Context, enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) {
|
|
return nil, nil, p.err()
|
|
}
|
|
|
|
func (p unsupportedCMSPublisher) Publish(context.Context, enterpriseSiteCredential, cmsPublishRequest) (*cmsPublishResult, map[string]any, error) {
|
|
return nil, nil, p.err()
|
|
}
|
|
|
|
func (p unsupportedCMSPublisher) err() error {
|
|
cmsType := strings.TrimSpace(p.cmsType)
|
|
if cmsType == "" {
|
|
cmsType = "CMS"
|
|
}
|
|
return fmt.Errorf("unsupported cms type: %s", cmsType)
|
|
}
|
|
|
|
func newPBootCMSPublisher(client *http.Client) *pbootCMSPublisher {
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 20 * time.Second}
|
|
}
|
|
return &pbootCMSPublisher{client: client}
|
|
}
|
|
|
|
func (p *pbootCMSPublisher) Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) {
|
|
var payload map[string]any
|
|
if err := p.request(ctx, http.MethodGet, credential, "ping", nil, nil, &payload); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
capability := &EnterpriseSiteCapability{
|
|
CMSType: strings.TrimSpace(stringFromMap(payload, "cms_type")),
|
|
PluginVersion: stringPointerFromMap(payload, "plugin_version"),
|
|
SiteURL: strings.TrimSpace(stringFromMap(payload, "site_url")),
|
|
SiteName: stringPointerFromMap(payload, "site_name"),
|
|
APIAuth: boolFromMap(payload, "api_auth"),
|
|
}
|
|
if capability.CMSType == "" {
|
|
capability.CMSType = pbootCMSPlatformID
|
|
}
|
|
if capability.SiteURL == "" {
|
|
capability.SiteURL = credential.SiteURL
|
|
}
|
|
return capability, payload, nil
|
|
}
|
|
|
|
func (p *pbootCMSPublisher) Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) {
|
|
query := url.Values{}
|
|
if strings.TrimSpace(credential.DefaultAcode) != "" {
|
|
query.Set("acode", strings.TrimSpace(credential.DefaultAcode))
|
|
}
|
|
if strings.TrimSpace(credential.DefaultMcode) != "" {
|
|
query.Set("mcode", strings.TrimSpace(credential.DefaultMcode))
|
|
}
|
|
|
|
var rawItems []map[string]any
|
|
if err := p.request(ctx, http.MethodGet, credential, "categories", query, nil, &rawItems); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
items := make([]EnterpriseSiteCategoryItem, 0, len(rawItems))
|
|
for _, raw := range rawItems {
|
|
id := firstStringFromMap(raw, "id", "scode", "remote_id")
|
|
name := firstStringFromMap(raw, "title", "name")
|
|
if strings.TrimSpace(id) == "" || strings.TrimSpace(name) == "" {
|
|
continue
|
|
}
|
|
items = append(items, EnterpriseSiteCategoryItem{
|
|
RemoteID: strings.TrimSpace(id),
|
|
ParentRemoteID: pbootCMSOptionalString(firstStringFromMap(raw, "parent_id", "pcode")),
|
|
Name: strings.TrimSpace(name),
|
|
Slug: pbootCMSOptionalString(firstStringFromMap(raw, "slug", "filename")),
|
|
Raw: raw,
|
|
})
|
|
}
|
|
return items, rawItems, nil
|
|
}
|
|
|
|
func (p *pbootCMSPublisher) Publish(ctx context.Context, credential enterpriseSiteCredential, req cmsPublishRequest) (*cmsPublishResult, map[string]any, error) {
|
|
categoryID := strings.TrimSpace(req.CategoryID)
|
|
if categoryID == "" {
|
|
categoryID = strings.TrimSpace(credential.DefaultScode)
|
|
}
|
|
body := map[string]any{
|
|
"scode": categoryID,
|
|
"title": strings.TrimSpace(req.Title),
|
|
"content": strings.TrimSpace(req.ContentHTML),
|
|
"keywords": strings.TrimSpace(req.Keywords),
|
|
"description": strings.TrimSpace(req.Description),
|
|
"author": strings.TrimSpace(req.Author),
|
|
"source": strings.TrimSpace(req.Source),
|
|
"status": pbootCMSStatusFromPublishType(req.PublishType),
|
|
}
|
|
if strings.TrimSpace(req.CoverURL) != "" {
|
|
body["ico"] = strings.TrimSpace(req.CoverURL)
|
|
}
|
|
if strings.TrimSpace(credential.DefaultAcode) != "" {
|
|
body["acode"] = strings.TrimSpace(credential.DefaultAcode)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := p.request(ctx, http.MethodPost, credential, "publish", nil, body, &payload); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
remoteID := firstStringFromMap(payload, "id", "remote_id")
|
|
result := &cmsPublishResult{
|
|
RemoteID: strings.TrimSpace(remoteID),
|
|
URL: strings.TrimSpace(firstStringFromMap(payload, "url", "external_article_url")),
|
|
Status: strings.TrimSpace(firstStringFromMap(payload, "status")),
|
|
}
|
|
if result.Status == "" {
|
|
result.Status = "published"
|
|
}
|
|
return result, payload, nil
|
|
}
|
|
|
|
func (p *pbootCMSPublisher) request(ctx context.Context, method string, credential enterpriseSiteCredential, action string, query url.Values, body any, out any) error {
|
|
endpoints, err := pbootCMSEndpoints(credential.SiteURL, action)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var lastErr error
|
|
for _, endpoint := range endpoints {
|
|
if err := p.requestEndpoint(ctx, method, endpoint, credential, action, query, body, out); err != nil {
|
|
lastErr = err
|
|
if !pbootCMSRouteUnavailable(err) {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
return nil
|
|
}
|
|
if lastErr != nil {
|
|
return lastErr
|
|
}
|
|
return fmt.Errorf("pbootcms %s endpoint is unavailable", action)
|
|
}
|
|
|
|
func (p *pbootCMSPublisher) requestEndpoint(ctx context.Context, method string, endpoint *url.URL, credential enterpriseSiteCredential, action string, query url.Values, body any, out any) error {
|
|
requestEndpoint := *endpoint
|
|
requestQuery := requestEndpoint.Query()
|
|
for key, values := range query {
|
|
requestQuery.Del(key)
|
|
for _, value := range values {
|
|
requestQuery.Add(key, value)
|
|
}
|
|
}
|
|
p.signQuery(credential, requestQuery)
|
|
requestEndpoint.RawQuery = requestQuery.Encode()
|
|
|
|
var reader io.Reader
|
|
if body != nil {
|
|
raw, marshalErr := json.Marshal(body)
|
|
if marshalErr != nil {
|
|
return fmt.Errorf("marshal pbootcms request: %w", marshalErr)
|
|
}
|
|
reader = bytes.NewReader(raw)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, method, requestEndpoint.String(), reader)
|
|
if err != nil {
|
|
return fmt.Errorf("build pbootcms request: %w", err)
|
|
}
|
|
httpReq.Header.Set("User-Agent", "GeoRankly-PBootCMS/1.0")
|
|
if body != nil {
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := p.client.Do(httpReq)
|
|
if err != nil {
|
|
return fmt.Errorf("request pbootcms %s: %w", action, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
|
if err != nil {
|
|
return fmt.Errorf("read pbootcms response: %w", err)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return &pbootCMSRequestError{
|
|
message: fmt.Sprintf("pbootcms %s returned http %d", action, resp.StatusCode),
|
|
routeUnavailable: resp.StatusCode == http.StatusNotFound,
|
|
}
|
|
}
|
|
|
|
var envelope pbootCMSResponse
|
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
|
rawText := strings.TrimSpace(string(raw))
|
|
return &pbootCMSRequestError{
|
|
message: fmt.Sprintf("parse pbootcms response: %v", err),
|
|
routeUnavailable: pbootCMSLooksLikeMissingRoute(rawText),
|
|
}
|
|
}
|
|
if envelope.Code != 1 {
|
|
msg := strings.TrimSpace(envelope.Msg)
|
|
if msg == "" {
|
|
msg = strings.TrimSpace(string(envelope.Data))
|
|
}
|
|
if msg == "" {
|
|
msg = fmt.Sprintf("pbootcms %s failed with code %d", action, envelope.Code)
|
|
}
|
|
return &pbootCMSRequestError{
|
|
message: msg,
|
|
routeUnavailable: strings.Contains(msg, "页面类文件不存在") || strings.Contains(strings.ToLower(msg), "not found"),
|
|
}
|
|
}
|
|
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
|
return fmt.Errorf("parse pbootcms data: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *pbootCMSPublisher) signQuery(credential enterpriseSiteCredential, query url.Values) {
|
|
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
|
appid := strings.TrimSpace(credential.AppID)
|
|
secret := strings.TrimSpace(credential.Secret)
|
|
query.Set("appid", appid)
|
|
query.Set("timestamp", timestamp)
|
|
query.Set("signature", pbootCMSSignature(appid, secret, timestamp))
|
|
}
|
|
|
|
func pbootCMSEndpoints(siteURL string, action string) ([]*url.URL, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(siteURL))
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return nil, fmt.Errorf("invalid pbootcms site url")
|
|
}
|
|
basePath := strings.TrimRight(parsed.Path, "/")
|
|
cleanAction := strings.TrimLeft(action, "/")
|
|
|
|
pretty := *parsed
|
|
pretty.Path = basePath + "/api/GeoPublisher/" + cleanAction
|
|
pretty.RawQuery = ""
|
|
pretty.Fragment = ""
|
|
|
|
queryEntry := *parsed
|
|
queryEntry.Path = basePath + "/api.php"
|
|
query := url.Values{}
|
|
query.Set("p", "/GeoPublisher/"+cleanAction)
|
|
queryEntry.RawQuery = query.Encode()
|
|
queryEntry.Fragment = ""
|
|
|
|
return []*url.URL{&pretty, &queryEntry}, nil
|
|
}
|
|
|
|
func pbootCMSSignature(appid, secret, timestamp string) string {
|
|
first := md5.Sum([]byte(appid + secret + timestamp))
|
|
firstHex := hex.EncodeToString(first[:])
|
|
second := md5.Sum([]byte(firstHex))
|
|
return hex.EncodeToString(second[:])
|
|
}
|
|
|
|
func pbootCMSStatusFromPublishType(publishType string) int {
|
|
if strings.TrimSpace(publishType) == "draft" {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func pbootCMSRouteUnavailable(err error) bool {
|
|
requestErr, ok := err.(*pbootCMSRequestError)
|
|
return ok && requestErr.routeUnavailable
|
|
}
|
|
|
|
func pbootCMSLooksLikeMissingRoute(text string) bool {
|
|
text = strings.ToLower(strings.TrimSpace(text))
|
|
return strings.Contains(text, "页面类文件不存在") ||
|
|
strings.Contains(text, "页面不存在") ||
|
|
strings.Contains(text, "404") ||
|
|
strings.Contains(text, "not found")
|
|
}
|
|
|
|
func firstStringFromMap(payload map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value := stringFromMap(payload, key); strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func stringFromMap(payload map[string]any, key string) string {
|
|
if payload == nil {
|
|
return ""
|
|
}
|
|
value, ok := payload[key]
|
|
if !ok || value == nil {
|
|
return ""
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed
|
|
case float64:
|
|
if typed == float64(int64(typed)) {
|
|
return strconv.FormatInt(int64(typed), 10)
|
|
}
|
|
return strconv.FormatFloat(typed, 'f', -1, 64)
|
|
case int:
|
|
return strconv.Itoa(typed)
|
|
case int64:
|
|
return strconv.FormatInt(typed, 10)
|
|
case bool:
|
|
if typed {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
default:
|
|
return fmt.Sprintf("%v", typed)
|
|
}
|
|
}
|
|
|
|
func stringPointerFromMap(payload map[string]any, key string) *string {
|
|
if value := strings.TrimSpace(stringFromMap(payload, key)); value != "" {
|
|
return &value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func boolFromMap(payload map[string]any, key string) bool {
|
|
if payload == nil {
|
|
return false
|
|
}
|
|
switch value := payload[key].(type) {
|
|
case bool:
|
|
return value
|
|
case string:
|
|
return strings.EqualFold(strings.TrimSpace(value), "true") || strings.TrimSpace(value) == "1"
|
|
case float64:
|
|
return value != 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func pbootCMSOptionalString(value string) *string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || value == "0" {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|