From 473cf8af4848d3c702e3e53ecb164f341a7b822a Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 01:41:05 +0200 Subject: [PATCH 1/2] feat: blogs resource for articles and products on a connected site Reaches content the connected site already owns, by the platform's own ids: the blogs on a WordPress site or a Shopify store, the articles on a blog, and a Shopify store's products. update_article addresses the article by its own id and sends only the keyword arguments the caller passed, so editing a live post changes it in place instead of forking it into a duplicate. Reads need the posts scope; writes need publish. --- README.md | 1 + src/fopost/__init__.py | 6 + src/fopost/client.py | 2 + src/fopost/models.py | 51 ++++++++ src/fopost/resources/__init__.py | 2 + src/fopost/resources/blogs.py | 198 +++++++++++++++++++++++++++++++ tests/test_blogs.py | 136 +++++++++++++++++++++ 7 files changed, 396 insertions(+) create mode 100644 src/fopost/resources/blogs.py create mode 100644 tests/test_blogs.py diff --git a/README.md b/README.md index dda7888..433739f 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 2734221..3f5184e 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -198,6 +198,9 @@ RedditSubreddit, RedditSubredditRule, RedditSubredditRules, + RemoteArticle, + RemoteBlog, + RemoteProduct, RepurposeResult, RewriteResult, RewriteVariant, @@ -390,6 +393,9 @@ "InstagramStory", "InstagramStoryInsights", "LinkedInMention", + "RemoteArticle", + "RemoteBlog", + "RemoteProduct", "SlackChannel", "SlackIdentity", "InboxHandover", diff --git a/src/fopost/client.py b/src/fopost/client.py index f1617df..045c555 100644 --- a/src/fopost/client.py +++ b/src/fopost/client.py @@ -17,6 +17,7 @@ AiResource, AnalyticsResource, BlogResource, + BlogsResource, BroadcastsResource, ContactsResource, GoogleBusinessResource, @@ -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) diff --git a/src/fopost/models.py b/src/fopost/models.py index 2636f17..c84d48e 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -15,6 +15,9 @@ __all__ = [ "AccountGroup", + "RemoteArticle", + "RemoteBlog", + "RemoteProduct", "AccountMove", "AccountPlatformMetrics", "ACTIVITY_KINDS", @@ -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.""" diff --git a/src/fopost/resources/__init__.py b/src/fopost/resources/__init__.py index a3e4be0..ade3a96 100644 --- a/src/fopost/resources/__init__.py +++ b/src/fopost/resources/__init__.py @@ -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 @@ -23,6 +24,7 @@ "AnalyticsResource", "ActivityResource", "AdsResource", + "BlogsResource", "AiResource", "BlogResource", "BroadcastsResource", diff --git a/src/fopost/resources/blogs.py b/src/fopost/resources/blogs.py new file mode 100644 index 0000000..deb8ee5 --- /dev/null +++ b/src/fopost/resources/blogs.py @@ -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 + ) + ) + ) diff --git a/tests/test_blogs.py b/tests/test_blogs.py new file mode 100644 index 0000000..dcef910 --- /dev/null +++ b/tests/test_blogs.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + +ARTICLE = { + "id": "99", + "blog_id": "11", + "title": "Spring drop", + "body_html": "

Hello

", + "excerpt": "A short summary", + "status": "published", + "author_name": "Store Owner", + "tags": ["news"], + "image_url": None, + "url": "https://demo.myshopify.com/blogs/article/spring-drop", + "published_at": "2026-09-01T10:00:00Z", + "updated_at": None, +} + + +@respx.mock +def test_list_blogs_parses_every_blog(client: Fopost) -> None: + respx.get(f"{BASE_URL}/accounts/a1/blogs").mock( + return_value=httpx.Response( + 200, + json={"data": [{"id": "11", "title": "News", "handle": "news", "url": None}]}, + ) + ) + + blogs = client.blogs.list_blogs("a1") + + assert [b.id for b in blogs] == ["11"] + assert blogs[0].title == "News" + + +@respx.mock +def test_list_articles_passes_the_filters(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/accounts/a1/blogs/11/articles").mock( + return_value=httpx.Response(200, json={"data": [ARTICLE]}) + ) + + articles = client.blogs.list_articles("a1", "11", limit=5, status="draft", q="spring") + + assert dict(route.calls.last.request.url.params) == { + "limit": "5", + "status": "draft", + "q": "spring", + } + assert articles[0].id == "99" + assert articles[0].tags == ["news"] + + +@respx.mock +def test_update_article_changes_it_in_place(client: Fopost) -> None: + # The article id is in the path, which is what stops an edit from creating + # a second post on the site. + route = respx.patch(f"{BASE_URL}/accounts/a1/blogs/11/articles/99").mock( + return_value=httpx.Response(200, json={"data": ARTICLE}) + ) + + client.blogs.update_article("a1", "11", "99", title="Spring drop, restocked") + + assert route.calls.last.request.url.path.endswith("/accounts/a1/blogs/11/articles/99") + assert route.calls.last.request.method == "PATCH" + # Only what the caller set travels, so nothing else on the article is blanked. + body = route.calls.last.request.content + assert b"Spring drop, restocked" in body + # Nothing else is sent, so the rest of the article stays as it is. + assert b"body" not in body and b"status" not in body + + +@respx.mock +def test_create_article_sends_the_optional_fields_it_was_given(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/accounts/a1/blogs/11/articles").mock( + return_value=httpx.Response(200, json={"data": ARTICLE}) + ) + + client.blogs.create_article( + "a1", "11", title="Spring drop", body="Hello", status="draft", tags=["news"] + ) + + content = route.calls.last.request.content + assert b'"title":"Spring drop"' in content or b'"title": "Spring drop"' in content + assert b"news" in content + # Never sent, so the site keeps its own defaults. + assert b"author_name" not in content + assert b"image_url" not in content + + +@respx.mock +def test_delete_article_hits_the_article_route(client: Fopost) -> None: + route = respx.delete(f"{BASE_URL}/accounts/a1/blogs/11/articles/99").mock( + return_value=httpx.Response(204) + ) + + client.blogs.delete_article("a1", "11", "99") + + assert route.called + + +@respx.mock +def test_update_product_sends_only_what_changed(client: Fopost) -> None: + route = respx.patch(f"{BASE_URL}/accounts/a1/products/7").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "id": "7", + "title": "Mug XL", + "handle": "mug", + "status": "draft", + "description": None, + "vendor": None, + "product_type": "Drinkware", + "tags": [], + "image_url": None, + "url": None, + "price": "12.00", + "currency": "USD", + "updated_at": None, + } + }, + ) + ) + + product = client.blogs.update_product("a1", "7", title="Mug XL", product_type="Drinkware") + + content = route.calls.last.request.content + assert b"Mug XL" in content + assert b"Drinkware" in content + assert b"vendor" not in content + assert product.price == "12.00" From dd9a09a0d5faf270de82116d85fdc32d204cde06 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 15:13:05 +0200 Subject: [PATCH 2/2] chore(release): 0.3.0 --- CHANGELOG.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3346b40..49e7349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `fopost` are documented here. -## Unreleased +## 0.3.0 - 2026-10-18 ### Removed @@ -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`