Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

All notable changes to `fopost` are documented here.

## Unreleased
## 0.3.0 - 2026-10-18

### Removed

Expand All @@ -12,6 +12,18 @@ All notable changes to `fopost` are documented here.

### Added

- `accounts.list_reddit_subreddits`, `accounts.list_reddit_subreddit_rules`,
`accounts.list_reddit_flairs` and `accounts.set_reddit_default_subreddit` for Reddit
destinations (a flair id is valid only in its subreddit; `None` as the default falls back
to the profile page), `validate.subreddit(account_id=..., name=...)` returning a
`SubredditCheck` before a post exists, and `inbox.vote(item_id, direction)` with
`"up"`, `"down"` or `"none"` to take a vote back. `SocialAccount.reconnect_required` and
the inbox item's `vote` and `can_vote` are new fields.
- `client.blogs`: `list_blogs`, `list_articles`, `get_article`, `create_article`,
`update_article`, `delete_article`, `list_products` and `update_product` reach the blogs,
articles and products already on a connected site, addressed by the platform's own ids
(`RemoteBlog`, `RemoteArticle`, `RemoteProduct`). `update_article` edits the live article
in place and sends only the arguments passed. Reads need the `posts` scope, writes `publish`.
- `blog.suggest_seeds(workspace_id, seeds=None, prompt=None)` returns about 10 search terms
from the blog goal and topics (`ai` scope; an empty balance raises `PaymentRequiredError`).
- `blog.submit_article` moves a draft, failed or rejected article to `in_review` (`posts`
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ except FopostError as err:
| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` |
| `google_business` | `get_location`, `update_location`, `get_attributes`, `update_attributes`, `get_menus`, `replace_menus`, `get_services`, `replace_services`, `list_media`, `add_media`, `delete_media`, `list_place_actions`, `create_place_action`, `update_place_action`, `delete_place_action`, `get_verification_options`, `start_verification`, `complete_verification`, `get_performance`, `get_search_keywords`, `assign` |
| `blog` | `get_settings`, `update_settings`, `rotate_website_secret`, `test_website`, `list_keywords`, `suggest_seeds`, `plan_keywords`, `add_keyword`, `update_keyword`, `delete_keyword`, `list_articles`, `request_article`, `get_article`, `update_article`, `regenerate_article`, `submit_article`, `publish_article`, `reject_article`, `delete_article` |
| `blogs` | `list_blogs`, `list_articles`, `get_article`, `create_article`, `update_article`, `delete_article`, `list_products`, `update_product` |
| `validate` | `post`, `length`, `media`, `subreddit` |
| `whatsapp` | `get_profile`, `update_profile`, `request_display_name`, `set_username`, `list_templates`, `list_template_library`, `get_template`, `create_template`, `import_template`, `update_template`, `delete_template`, `list_groups`, `create_group`, `get_group`, `update_group`, `delete_group`, `get_group_invite_link`, `reset_group_invite_link`, `remove_group_participants`, `list_blocked`, `block_users`, `unblock_users`, `get_commerce_settings`, `update_commerce_settings`, `link_catalog`, `list_flows`, `get_flow`, `create_flow`, `update_flow`, `delete_flow`, `upload_flow_json`, `publish_flow`, `deprecate_flow`, `list_flow_responses`, `get_encryption_key_status`, `set_encryption_key`, `get_account_events`, `list_sandbox_sessions`, `create_sandbox_session` |
| `analytics` | `decay`, `frequency`, `timeline`, `changes`, `collect_post`, `native_posts` |
Expand Down
6 changes: 6 additions & 0 deletions src/fopost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@
RedditSubreddit,
RedditSubredditRule,
RedditSubredditRules,
RemoteArticle,
RemoteBlog,
RemoteProduct,
RepurposeResult,
RewriteResult,
RewriteVariant,
Expand Down Expand Up @@ -390,6 +393,9 @@
"InstagramStory",
"InstagramStoryInsights",
"LinkedInMention",
"RemoteArticle",
"RemoteBlog",
"RemoteProduct",
"SlackChannel",
"SlackIdentity",
"InboxHandover",
Expand Down
2 changes: 2 additions & 0 deletions src/fopost/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
AiResource,
AnalyticsResource,
BlogResource,
BlogsResource,
BroadcastsResource,
ContactsResource,
GoogleBusinessResource,
Expand Down Expand Up @@ -84,6 +85,7 @@ def __init__(
self.broadcasts = BroadcastsResource(self._http)
self.sequences = SequencesResource(self._http)
self.ads = AdsResource(self._http)
self.blogs = BlogsResource(self._http)
self.validate = ValidateResource(self._http)
self.analytics = AnalyticsResource(self._http)
self.knowledge = KnowledgeResource(self._http)
Expand Down
51 changes: 51 additions & 0 deletions src/fopost/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@

__all__ = [
"AccountGroup",
"RemoteArticle",
"RemoteBlog",
"RemoteProduct",
"AccountMove",
"AccountPlatformMetrics",
"ACTIVITY_KINDS",
Expand Down Expand Up @@ -688,6 +691,54 @@ class LinkedInMention(FopostModel):
annotation: str


class RemoteBlog(FopostModel):
"""A blog on a connected site. ``id`` is the platform's, never FoPost's.

WordPress has one implicit blog and reports it as ``default``; a Shopify
store reports every blog it has.
"""

id: str
title: str
handle: str | None = None
url: str | None = None


class RemoteArticle(FopostModel):
"""An article that already lives on a connected site."""

id: str
blog_id: str | None = None
title: str
body_html: str | None = None
excerpt: str | None = None
status: str
author_name: str | None = None
tags: list[str] = []
image_url: str | None = None
url: str | None = None
published_at: datetime | None = None
updated_at: datetime | None = None


class RemoteProduct(FopostModel):
"""A product on a connected store. ``price`` is the lowest variant price."""

id: str
title: str
handle: str | None = None
status: str
description: str | None = None
vendor: str | None = None
product_type: str | None = None
tags: list[str] = []
image_url: str | None = None
url: str | None = None
price: str | None = None
currency: str | None = None
updated_at: datetime | None = None


class SlackChannel(FopostModel):
"""``is_current`` marks the channel this account posts to."""

Expand Down
2 changes: 2 additions & 0 deletions src/fopost/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .ai import AiResource
from .analytics import AnalyticsResource
from .blog import BlogResource
from .blogs import BlogsResource
from .broadcasts import BroadcastsResource, SequencesResource
from .contacts import ContactsResource
from .google_business import GoogleBusinessResource
Expand All @@ -23,6 +24,7 @@
"AnalyticsResource",
"ActivityResource",
"AdsResource",
"BlogsResource",
"AiResource",
"BlogResource",
"BroadcastsResource",
Expand Down
198 changes: 198 additions & 0 deletions src/fopost/resources/blogs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""``client.blogs`` — content a connected site already owns.

Most of this SDK creates content. These calls reach what is already there: the
articles on a WordPress site or a Shopify store's blog, and a Shopify store's
products. Every id below is the **platform's own**, never a FoPost id.

Reads need the ``posts`` scope. Anything that changes the site needs ``posts``
and ``publish``, because a change here is visible to the site's own readers.
"""

from __future__ import annotations

import builtins
from collections.abc import Sequence
from typing import Any

from .._http import unwrap
from ..models import RemoteArticle, RemoteBlog, RemoteProduct
from ._base import Resource, parse_list

__all__ = ["BlogsResource"]


class BlogsResource(Resource):
def list_blogs(self, account_id: str) -> builtins.list[RemoteBlog]:
"""Blogs the account can write to.

A Shopify store reports every blog it has; WordPress reports its one
implicit blog under the id ``default``, so both answer the same shape.
"""
return parse_list(RemoteBlog, unwrap(self._http.get(f"/accounts/{account_id}/blogs")))

def list_articles(
self,
account_id: str,
blog_id: str,
*,
limit: int | None = None,
status: str | None = None,
q: str | None = None,
) -> builtins.list[RemoteArticle]:
"""Articles on the blog, newest first, drafts included.

``status`` is one of ``published``, ``draft``, ``pending`` or
``scheduled``; ``q`` matches the title; ``limit`` is 1 to 50.
"""
return parse_list(
RemoteArticle,
unwrap(
self._http.get(
f"/accounts/{account_id}/blogs/{blog_id}/articles",
{"limit": limit, "status": status, "q": q},
)
),
)

def get_article(self, account_id: str, blog_id: str, article_id: str) -> RemoteArticle:
"""One article in full."""
return RemoteArticle.model_validate(
unwrap(self._http.get(f"/accounts/{account_id}/blogs/{blog_id}/articles/{article_id}"))
)

def create_article(
self,
account_id: str,
blog_id: str,
*,
title: str,
body: str,
excerpt: str | None = None,
status: str | None = None,
tags: Sequence[str] | None = None,
author_name: str | None = None,
image_url: str | None = None,
) -> RemoteArticle:
"""Write a new article to the blog. Needs the ``publish`` scope.

``body`` is FoPost body markup; the site's own format is rendered from it.
"""
payload: dict[str, Any] = {"title": title, "body": body}
if excerpt is not None:
payload["excerpt"] = excerpt
if status is not None:
payload["status"] = status
if tags is not None:
payload["tags"] = list(tags)
if author_name is not None:
payload["author_name"] = author_name
if image_url is not None:
payload["image_url"] = image_url
return RemoteArticle.model_validate(
unwrap(self._http.post(f"/accounts/{account_id}/blogs/{blog_id}/articles", payload))
)

def update_article(
self,
account_id: str,
blog_id: str,
article_id: str,
*,
title: str | None = None,
body: str | None = None,
excerpt: str | None = None,
status: str | None = None,
tags: Sequence[str] | None = None,
author_name: str | None = None,
image_url: str | None = None,
) -> RemoteArticle:
"""Change the live article in place. Needs the ``publish`` scope.

Only the fields passed here are touched, and the article is addressed by
its own id, so an edit never creates a second post on the site. At least
one field is required.
"""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if excerpt is not None:
payload["excerpt"] = excerpt
if status is not None:
payload["status"] = status
if tags is not None:
payload["tags"] = list(tags)
if author_name is not None:
payload["author_name"] = author_name
if image_url is not None:
payload["image_url"] = image_url
return RemoteArticle.model_validate(
unwrap(
self._http.request(
"PATCH",
f"/accounts/{account_id}/blogs/{blog_id}/articles/{article_id}",
json=payload,
)
)
)

def delete_article(self, account_id: str, blog_id: str, article_id: str) -> None:
"""Remove the article from the site. Needs ``publish``; cannot be undone."""
self._http.delete(f"/accounts/{account_id}/blogs/{blog_id}/articles/{article_id}")

def list_products(
self,
account_id: str,
*,
limit: int | None = None,
status: str | None = None,
q: str | None = None,
) -> builtins.list[RemoteProduct]:
"""The store's products. ``status`` is ``active``, ``draft`` or ``archived``."""
return parse_list(
RemoteProduct,
unwrap(
self._http.get(
f"/accounts/{account_id}/products",
{"limit": limit, "status": status, "q": q},
)
),
)

def update_product(
self,
account_id: str,
product_id: str,
*,
title: str | None = None,
description: str | None = None,
status: str | None = None,
tags: Sequence[str] | None = None,
product_type: str | None = None,
vendor: str | None = None,
) -> RemoteProduct:
"""Change a product on the store. Needs the ``publish`` scope.

Only the fields passed here change; at least one is required.
"""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if status is not None:
payload["status"] = status
if tags is not None:
payload["tags"] = list(tags)
if product_type is not None:
payload["product_type"] = product_type
if vendor is not None:
payload["vendor"] = vendor
return RemoteProduct.model_validate(
unwrap(
self._http.request(
"PATCH", f"/accounts/{account_id}/products/{product_id}", json=payload
)
)
)
Loading
Loading