meta-ads-mcp 0.7.8__tar.gz → 0.7.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/PKG-INFO +1 -1
- meta_ads_mcp-0.7.9/RELEASE.md +166 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/ads.py +16 -4
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/utils.py +18 -6
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/pyproject.toml +1 -1
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_get_ad_creatives_fix.py +3 -3
- meta_ads_mcp-0.7.9/tests/test_get_ad_image_quality_improvements.py +391 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_get_ad_image_regression.py +74 -2
- meta_ads_mcp-0.7.8/RELEASE.md +0 -97
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/.gitignore +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/Dockerfile +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/LICENSE +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/README.md +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/examples/README.md +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/future_improvements.md +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/accounts.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/adsets.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/openai_deep_research.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/targeting.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/requirements.txt +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/setup.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/smithery.yaml +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/README.md +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_account_search.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_budget_update.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_budget_update_e2e.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_dsa_beneficiary.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_dsa_integration.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_insights_actions_and_values.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_integration_openai_mcp.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_openai.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_openai_mcp_deep_research.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_targeting.py +0 -0
- {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_targeting_search_e2e.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meta-ads-mcp
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.9
|
|
4
4
|
Summary: Model Context Protocol (MCP) plugin for interacting with Meta Ads API
|
|
5
5
|
Project-URL: Homepage, https://github.com/pipeboard-co/meta-ads-mcp
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/pipeboard-co/meta-ads-mcp/issues
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Release Process
|
|
2
|
+
|
|
3
|
+
This repository uses GitHub Actions to automatically publish releases to PyPI. Here's the optimized release process:
|
|
4
|
+
|
|
5
|
+
## 🚀 Quick Release (Recommended)
|
|
6
|
+
|
|
7
|
+
### Prerequisites
|
|
8
|
+
- ✅ **Trusted Publishing Configured**: Repository uses PyPI trusted publishing with OIDC tokens
|
|
9
|
+
- ✅ **GitHub CLI installed**: `gh` command available for streamlined releases
|
|
10
|
+
- ✅ **Clean working directory**: No uncommitted changes
|
|
11
|
+
|
|
12
|
+
### Optimal Release Process
|
|
13
|
+
|
|
14
|
+
1. **Update version in both files** (use consistent versioning):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Update pyproject.toml
|
|
18
|
+
sed -i '' 's/version = "0.7.7"/version = "0.7.8"/' pyproject.toml
|
|
19
|
+
|
|
20
|
+
# Update __init__.py
|
|
21
|
+
sed -i '' 's/__version__ = "0.7.7"/__version__ = "0.7.8"/' meta_ads_mcp/__init__.py
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or manually edit:
|
|
25
|
+
- `pyproject.toml`: `version = "0.7.8"`
|
|
26
|
+
- `meta_ads_mcp/__init__.py`: `__version__ = "0.7.8"`
|
|
27
|
+
|
|
28
|
+
2. **Commit and push version changes**:
|
|
29
|
+
```bash
|
|
30
|
+
git add pyproject.toml meta_ads_mcp/__init__.py
|
|
31
|
+
git commit -m "Bump version to 0.7.8"
|
|
32
|
+
git push origin main
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
3. **Create GitHub release** (triggers automatic PyPI publishing):
|
|
36
|
+
```bash
|
|
37
|
+
# Use bash wrapper if gh has issues in Cursor
|
|
38
|
+
bash -c "gh release create 0.7.8 --title '0.7.8' --generate-notes"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
4. **Verify release** (optional):
|
|
42
|
+
```bash
|
|
43
|
+
# Check GitHub release
|
|
44
|
+
curl -s "https://api.github.com/repos/pipeboard-co/meta-ads-mcp/releases/latest" | grep -E '"tag_name"|"name"'
|
|
45
|
+
|
|
46
|
+
# Check PyPI availability (wait 2-3 minutes)
|
|
47
|
+
curl -s "https://pypi.org/pypi/meta-ads-mcp/json" | grep -E '"version"|"0.7.8"'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 📋 Detailed Release Process
|
|
51
|
+
|
|
52
|
+
### Version Management Best Practices
|
|
53
|
+
|
|
54
|
+
- **Semantic Versioning**: Follow `MAJOR.MINOR.PATCH` (e.g., 0.7.8)
|
|
55
|
+
- **Synchronized Files**: Always update BOTH version files
|
|
56
|
+
- **Commit Convention**: Use `"Bump version to X.Y.Z"` format
|
|
57
|
+
- **Release Tag**: GitHub release tag matches version (no "v" prefix)
|
|
58
|
+
|
|
59
|
+
### Pre-Release Checklist
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# 1. Ensure clean working directory
|
|
63
|
+
git status
|
|
64
|
+
|
|
65
|
+
# 2. Run tests locally (optional but recommended)
|
|
66
|
+
uv run python -m pytest tests/ -v
|
|
67
|
+
|
|
68
|
+
# 3. Check current version
|
|
69
|
+
grep -E "version =|__version__" pyproject.toml meta_ads_mcp/__init__.py
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Release Commands (One-liner)
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Complete release in one sequence
|
|
76
|
+
VERSION="0.7.8" && \
|
|
77
|
+
sed -i '' "s/version = \"0.7.7\"/version = \"$VERSION\"/" pyproject.toml && \
|
|
78
|
+
sed -i '' "s/__version__ = \"0.7.7\"/__version__ = \"$VERSION\"/" meta_ads_mcp/__init__.py && \
|
|
79
|
+
git add pyproject.toml meta_ads_mcp/__init__.py && \
|
|
80
|
+
git commit -m "Bump version to $VERSION" && \
|
|
81
|
+
git push origin main && \
|
|
82
|
+
bash -c "gh release create $VERSION --title '$VERSION' --generate-notes"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## 🔄 Workflows
|
|
86
|
+
|
|
87
|
+
### `publish.yml` (Automatic)
|
|
88
|
+
- **Trigger**: GitHub release creation
|
|
89
|
+
- **Purpose**: Build and publish to PyPI
|
|
90
|
+
- **Security**: OIDC tokens (no API keys)
|
|
91
|
+
- **Status**: ✅ Fully automated
|
|
92
|
+
|
|
93
|
+
### `test.yml` (Validation)
|
|
94
|
+
- **Trigger**: Push to main/master
|
|
95
|
+
- **Purpose**: Package structure validation
|
|
96
|
+
- **Matrix**: Python 3.10, 3.11, 3.12
|
|
97
|
+
- **Note**: Build tests only, not pytest
|
|
98
|
+
|
|
99
|
+
## 🛠️ Troubleshooting
|
|
100
|
+
|
|
101
|
+
### Common Issues
|
|
102
|
+
|
|
103
|
+
1. **gh command issues in Cursor**:
|
|
104
|
+
```bash
|
|
105
|
+
# Use bash wrapper
|
|
106
|
+
bash -c "gh release create 0.7.8 --title '0.7.8' --generate-notes"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
2. **Version mismatch**:
|
|
110
|
+
```bash
|
|
111
|
+
# Verify both files have same version
|
|
112
|
+
grep -E "version =|__version__" pyproject.toml meta_ads_mcp/__init__.py
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
3. **PyPI not updated**:
|
|
116
|
+
```bash
|
|
117
|
+
# Check if package is available (wait 2-3 minutes)
|
|
118
|
+
curl -s "https://pypi.org/pypi/meta-ads-mcp/json" | grep '"version"'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Manual Deployment (Fallback)
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
# Install build tools
|
|
125
|
+
pip install build twine
|
|
126
|
+
|
|
127
|
+
# Build package
|
|
128
|
+
python -m build
|
|
129
|
+
|
|
130
|
+
# Upload to PyPI (requires API token)
|
|
131
|
+
python -m twine upload dist/*
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## 📊 Release Verification
|
|
135
|
+
|
|
136
|
+
### GitHub Release
|
|
137
|
+
- ✅ Release created with correct tag
|
|
138
|
+
- ✅ Auto-generated notes from commits
|
|
139
|
+
- ✅ Actions tab shows successful workflow
|
|
140
|
+
|
|
141
|
+
### PyPI Package
|
|
142
|
+
- ✅ Package available for installation
|
|
143
|
+
- ✅ Correct version displayed
|
|
144
|
+
- ✅ All dependencies listed
|
|
145
|
+
|
|
146
|
+
### Installation Test
|
|
147
|
+
```bash
|
|
148
|
+
# Test new version installation
|
|
149
|
+
pip install meta-ads-mcp==0.7.8
|
|
150
|
+
# or
|
|
151
|
+
uvx meta-ads-mcp@0.7.8
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## 🔒 Security Notes
|
|
155
|
+
|
|
156
|
+
- **Trusted Publishing**: Uses GitHub OIDC tokens (no API keys needed)
|
|
157
|
+
- **Isolated Builds**: All builds run in GitHub-hosted runners
|
|
158
|
+
- **Access Control**: Only maintainers can create releases
|
|
159
|
+
- **Audit Trail**: All releases tracked in GitHub Actions
|
|
160
|
+
|
|
161
|
+
## 📈 Release Metrics
|
|
162
|
+
|
|
163
|
+
Track successful releases:
|
|
164
|
+
- **GitHub Releases**: https://github.com/pipeboard-co/meta-ads-mcp/releases
|
|
165
|
+
- **PyPI Package**: https://pypi.org/project/meta-ads-mcp/
|
|
166
|
+
- **Actions History**: https://github.com/pipeboard-co/meta-ads-mcp/actions
|
|
@@ -171,7 +171,7 @@ async def get_ad_creatives(access_token: str = None, ad_id: str = None) -> str:
|
|
|
171
171
|
|
|
172
172
|
endpoint = f"{ad_id}/adcreatives"
|
|
173
173
|
params = {
|
|
174
|
-
"fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec"
|
|
174
|
+
"fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,asset_feed_spec,image_urls_for_viewing"
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
data = await make_api_request(endpoint, access_token, params)
|
|
@@ -279,14 +279,26 @@ async def get_ad_image(access_token: str = None, ad_id: str = None) -> Image:
|
|
|
279
279
|
if "data" in creative_data and creative_data["data"]:
|
|
280
280
|
creative = creative_data["data"][0]
|
|
281
281
|
|
|
282
|
-
#
|
|
282
|
+
# Prioritize higher quality image URLs in this order:
|
|
283
|
+
# 1. image_urls_for_viewing (usually highest quality)
|
|
284
|
+
# 2. image_url (direct field)
|
|
285
|
+
# 3. object_story_spec.link_data.picture (usually full size)
|
|
286
|
+
# 4. thumbnail_url (last resort - often profile thumbnail)
|
|
287
|
+
|
|
283
288
|
if "image_urls_for_viewing" in creative and creative["image_urls_for_viewing"]:
|
|
284
289
|
image_url = creative["image_urls_for_viewing"][0]
|
|
285
290
|
print(f"Using image_urls_for_viewing: {image_url}")
|
|
286
|
-
|
|
291
|
+
elif "image_url" in creative and creative["image_url"]:
|
|
292
|
+
image_url = creative["image_url"]
|
|
293
|
+
print(f"Using image_url: {image_url}")
|
|
294
|
+
elif "object_story_spec" in creative and "link_data" in creative["object_story_spec"]:
|
|
295
|
+
link_data = creative["object_story_spec"]["link_data"]
|
|
296
|
+
if "picture" in link_data and link_data["picture"]:
|
|
297
|
+
image_url = link_data["picture"]
|
|
298
|
+
print(f"Using object_story_spec.link_data.picture: {image_url}")
|
|
287
299
|
elif "thumbnail_url" in creative and creative["thumbnail_url"]:
|
|
288
300
|
image_url = creative["thumbnail_url"]
|
|
289
|
-
print(f"Using thumbnail_url: {image_url}")
|
|
301
|
+
print(f"Using thumbnail_url (fallback): {image_url}")
|
|
290
302
|
|
|
291
303
|
if not image_url:
|
|
292
304
|
return "Error: No image URLs found in creative"
|
|
@@ -78,23 +78,31 @@ ad_creative_images = {}
|
|
|
78
78
|
def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
|
|
79
79
|
"""
|
|
80
80
|
Extract image URLs from a creative object for direct viewing.
|
|
81
|
+
Prioritizes higher quality images over thumbnails.
|
|
81
82
|
|
|
82
83
|
Args:
|
|
83
84
|
creative: Meta Ads creative object
|
|
84
85
|
|
|
85
86
|
Returns:
|
|
86
|
-
List of image URLs found in the creative
|
|
87
|
+
List of image URLs found in the creative, prioritized by quality
|
|
87
88
|
"""
|
|
88
89
|
image_urls = []
|
|
89
90
|
|
|
91
|
+
# Prioritize higher quality image URLs in this order:
|
|
92
|
+
# 1. image_urls_for_viewing (usually highest quality)
|
|
93
|
+
# 2. image_url (direct field)
|
|
94
|
+
# 3. object_story_spec.link_data.picture (usually full size)
|
|
95
|
+
# 4. asset_feed_spec images (multiple high-quality images)
|
|
96
|
+
# 5. thumbnail_url (last resort - often profile thumbnail)
|
|
97
|
+
|
|
98
|
+
# Check for image_urls_for_viewing (highest priority)
|
|
99
|
+
if "image_urls_for_viewing" in creative and creative["image_urls_for_viewing"]:
|
|
100
|
+
image_urls.extend(creative["image_urls_for_viewing"])
|
|
101
|
+
|
|
90
102
|
# Check for direct image_url field
|
|
91
103
|
if "image_url" in creative and creative["image_url"]:
|
|
92
104
|
image_urls.append(creative["image_url"])
|
|
93
105
|
|
|
94
|
-
# Check for thumbnail_url field
|
|
95
|
-
if "thumbnail_url" in creative and creative["thumbnail_url"]:
|
|
96
|
-
image_urls.append(creative["thumbnail_url"])
|
|
97
|
-
|
|
98
106
|
# Check object_story_spec for image URLs
|
|
99
107
|
if "object_story_spec" in creative:
|
|
100
108
|
story_spec = creative["object_story_spec"]
|
|
@@ -103,7 +111,7 @@ def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
|
|
|
103
111
|
if "link_data" in story_spec:
|
|
104
112
|
link_data = story_spec["link_data"]
|
|
105
113
|
|
|
106
|
-
# Check for picture field
|
|
114
|
+
# Check for picture field (usually full size)
|
|
107
115
|
if "picture" in link_data and link_data["picture"]:
|
|
108
116
|
image_urls.append(link_data["picture"])
|
|
109
117
|
|
|
@@ -121,6 +129,10 @@ def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
|
|
|
121
129
|
if "url" in image and image["url"]:
|
|
122
130
|
image_urls.append(image["url"])
|
|
123
131
|
|
|
132
|
+
# Check for thumbnail_url field (lowest priority)
|
|
133
|
+
if "thumbnail_url" in creative and creative["thumbnail_url"]:
|
|
134
|
+
image_urls.append(creative["thumbnail_url"])
|
|
135
|
+
|
|
124
136
|
# Remove duplicates while preserving order
|
|
125
137
|
seen = set()
|
|
126
138
|
unique_urls = []
|
|
@@ -90,11 +90,11 @@ class TestGetAdCreativesBugFix:
|
|
|
90
90
|
|
|
91
91
|
urls = extract_creative_image_urls(test_creative)
|
|
92
92
|
|
|
93
|
-
# Should extract URLs in order: image_url,
|
|
93
|
+
# Should extract URLs in order: image_url, picture, thumbnail_url (new priority order)
|
|
94
94
|
expected_urls = [
|
|
95
95
|
"https://example.com/image.jpg",
|
|
96
|
-
"https://example.com/
|
|
97
|
-
"https://example.com/
|
|
96
|
+
"https://example.com/picture.jpg",
|
|
97
|
+
"https://example.com/thumb.jpg"
|
|
98
98
|
]
|
|
99
99
|
|
|
100
100
|
assert urls == expected_urls
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Tests for get_ad_image quality improvements.
|
|
2
|
+
|
|
3
|
+
These tests verify that the get_ad_image function now correctly prioritizes
|
|
4
|
+
high-quality ad creative images over profile thumbnails.
|
|
5
|
+
|
|
6
|
+
Key improvements tested:
|
|
7
|
+
1. Prioritizes image_urls_for_viewing over thumbnail_url
|
|
8
|
+
2. Uses image_url as second priority
|
|
9
|
+
3. Uses object_story_spec.link_data.picture as third priority
|
|
10
|
+
4. Only uses thumbnail_url as last resort
|
|
11
|
+
5. Better logging to show which image source is being used
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
import json
|
|
16
|
+
from unittest.mock import AsyncMock, patch, MagicMock
|
|
17
|
+
from meta_ads_mcp.core.ads import get_ad_image
|
|
18
|
+
from meta_ads_mcp.core.utils import extract_creative_image_urls
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestGetAdImageQualityImprovements:
|
|
22
|
+
"""Test cases for image quality improvements in get_ad_image function."""
|
|
23
|
+
|
|
24
|
+
@pytest.mark.asyncio
|
|
25
|
+
async def test_prioritizes_image_urls_for_viewing_over_thumbnail(self):
|
|
26
|
+
"""Test that image_urls_for_viewing is prioritized over thumbnail_url."""
|
|
27
|
+
|
|
28
|
+
# Mock responses for creative with both high-quality and thumbnail URLs
|
|
29
|
+
mock_ad_data = {
|
|
30
|
+
"account_id": "act_123456789",
|
|
31
|
+
"creative": {"id": "creative_123456789"}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
mock_creative_details = {
|
|
35
|
+
"id": "creative_123456789",
|
|
36
|
+
"name": "Test Creative"
|
|
37
|
+
# No image_hash - triggers fallback
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Mock get_ad_creatives response with both URLs
|
|
41
|
+
mock_get_ad_creatives_response = json.dumps({
|
|
42
|
+
"data": [
|
|
43
|
+
{
|
|
44
|
+
"id": "creative_123456789",
|
|
45
|
+
"name": "Test Creative",
|
|
46
|
+
"status": "ACTIVE",
|
|
47
|
+
"thumbnail_url": "https://example.com/thumbnail_64x64.jpg", # Low quality
|
|
48
|
+
"image_url": "https://example.com/full_image.jpg", # Medium quality
|
|
49
|
+
"image_urls_for_viewing": [
|
|
50
|
+
"https://example.com/high_quality_image.jpg", # Highest quality
|
|
51
|
+
"https://example.com/alt_high_quality.jpg"
|
|
52
|
+
],
|
|
53
|
+
"object_story_spec": {
|
|
54
|
+
"link_data": {
|
|
55
|
+
"picture": "https://example.com/object_story_picture.jpg"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
# Mock PIL Image processing
|
|
63
|
+
mock_pil_image = MagicMock()
|
|
64
|
+
mock_pil_image.mode = "RGB"
|
|
65
|
+
mock_pil_image.convert.return_value = mock_pil_image
|
|
66
|
+
|
|
67
|
+
mock_byte_stream = MagicMock()
|
|
68
|
+
mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
|
|
69
|
+
|
|
70
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
|
|
71
|
+
patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
|
|
72
|
+
patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
|
|
73
|
+
patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
|
|
74
|
+
patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
|
|
75
|
+
|
|
76
|
+
mock_api.side_effect = [mock_ad_data, mock_creative_details]
|
|
77
|
+
mock_get_creatives.return_value = mock_get_ad_creatives_response
|
|
78
|
+
mock_download.return_value = b"fake_image_bytes"
|
|
79
|
+
mock_pil_open.return_value = mock_pil_image
|
|
80
|
+
mock_bytesio.return_value = mock_byte_stream
|
|
81
|
+
|
|
82
|
+
# This should prioritize image_urls_for_viewing[0]
|
|
83
|
+
result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
|
|
84
|
+
|
|
85
|
+
# Verify it used the highest quality URL
|
|
86
|
+
assert result is not None
|
|
87
|
+
mock_download.assert_called_once_with("https://example.com/high_quality_image.jpg")
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_falls_back_to_image_url_when_image_urls_for_viewing_unavailable(self):
|
|
91
|
+
"""Test fallback to image_url when image_urls_for_viewing is not available."""
|
|
92
|
+
|
|
93
|
+
# Mock responses for creative without image_urls_for_viewing
|
|
94
|
+
mock_ad_data = {
|
|
95
|
+
"account_id": "act_123456789",
|
|
96
|
+
"creative": {"id": "creative_123456789"}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
mock_creative_details = {
|
|
100
|
+
"id": "creative_123456789",
|
|
101
|
+
"name": "Test Creative"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Mock get_ad_creatives response without image_urls_for_viewing
|
|
105
|
+
mock_get_ad_creatives_response = json.dumps({
|
|
106
|
+
"data": [
|
|
107
|
+
{
|
|
108
|
+
"id": "creative_123456789",
|
|
109
|
+
"name": "Test Creative",
|
|
110
|
+
"status": "ACTIVE",
|
|
111
|
+
"thumbnail_url": "https://example.com/thumbnail.jpg",
|
|
112
|
+
"image_url": "https://example.com/full_image.jpg", # Should be used
|
|
113
|
+
"object_story_spec": {
|
|
114
|
+
"link_data": {
|
|
115
|
+
"picture": "https://example.com/object_story_picture.jpg"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
# Mock PIL Image processing
|
|
123
|
+
mock_pil_image = MagicMock()
|
|
124
|
+
mock_pil_image.mode = "RGB"
|
|
125
|
+
mock_pil_image.convert.return_value = mock_pil_image
|
|
126
|
+
|
|
127
|
+
mock_byte_stream = MagicMock()
|
|
128
|
+
mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
|
|
129
|
+
|
|
130
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
|
|
131
|
+
patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
|
|
132
|
+
patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
|
|
133
|
+
patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
|
|
134
|
+
patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
|
|
135
|
+
|
|
136
|
+
mock_api.side_effect = [mock_ad_data, mock_creative_details]
|
|
137
|
+
mock_get_creatives.return_value = mock_get_ad_creatives_response
|
|
138
|
+
mock_download.return_value = b"fake_image_bytes"
|
|
139
|
+
mock_pil_open.return_value = mock_pil_image
|
|
140
|
+
mock_bytesio.return_value = mock_byte_stream
|
|
141
|
+
|
|
142
|
+
# This should fall back to image_url
|
|
143
|
+
result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
|
|
144
|
+
|
|
145
|
+
# Verify it used image_url
|
|
146
|
+
assert result is not None
|
|
147
|
+
mock_download.assert_called_once_with("https://example.com/full_image.jpg")
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_falls_back_to_object_story_spec_picture_when_image_url_unavailable(self):
|
|
151
|
+
"""Test fallback to object_story_spec.link_data.picture when image_url is not available."""
|
|
152
|
+
|
|
153
|
+
# Mock responses for creative without image_url
|
|
154
|
+
mock_ad_data = {
|
|
155
|
+
"account_id": "act_123456789",
|
|
156
|
+
"creative": {"id": "creative_123456789"}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
mock_creative_details = {
|
|
160
|
+
"id": "creative_123456789",
|
|
161
|
+
"name": "Test Creative"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Mock get_ad_creatives response without image_url
|
|
165
|
+
mock_get_ad_creatives_response = json.dumps({
|
|
166
|
+
"data": [
|
|
167
|
+
{
|
|
168
|
+
"id": "creative_123456789",
|
|
169
|
+
"name": "Test Creative",
|
|
170
|
+
"status": "ACTIVE",
|
|
171
|
+
"thumbnail_url": "https://example.com/thumbnail.jpg",
|
|
172
|
+
"object_story_spec": {
|
|
173
|
+
"link_data": {
|
|
174
|
+
"picture": "https://example.com/object_story_picture.jpg" # Should be used
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
# Mock PIL Image processing
|
|
182
|
+
mock_pil_image = MagicMock()
|
|
183
|
+
mock_pil_image.mode = "RGB"
|
|
184
|
+
mock_pil_image.convert.return_value = mock_pil_image
|
|
185
|
+
|
|
186
|
+
mock_byte_stream = MagicMock()
|
|
187
|
+
mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
|
|
188
|
+
|
|
189
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
|
|
190
|
+
patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
|
|
191
|
+
patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
|
|
192
|
+
patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
|
|
193
|
+
patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
|
|
194
|
+
|
|
195
|
+
mock_api.side_effect = [mock_ad_data, mock_creative_details]
|
|
196
|
+
mock_get_creatives.return_value = mock_get_ad_creatives_response
|
|
197
|
+
mock_download.return_value = b"fake_image_bytes"
|
|
198
|
+
mock_pil_open.return_value = mock_pil_image
|
|
199
|
+
mock_bytesio.return_value = mock_byte_stream
|
|
200
|
+
|
|
201
|
+
# This should fall back to object_story_spec.link_data.picture
|
|
202
|
+
result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
|
|
203
|
+
|
|
204
|
+
# Verify it used object_story_spec.link_data.picture
|
|
205
|
+
assert result is not None
|
|
206
|
+
mock_download.assert_called_once_with("https://example.com/object_story_picture.jpg")
|
|
207
|
+
|
|
208
|
+
@pytest.mark.asyncio
|
|
209
|
+
async def test_uses_thumbnail_url_only_as_last_resort(self):
|
|
210
|
+
"""Test that thumbnail_url is only used when no other options are available."""
|
|
211
|
+
|
|
212
|
+
# Mock responses for creative with only thumbnail_url
|
|
213
|
+
mock_ad_data = {
|
|
214
|
+
"account_id": "act_123456789",
|
|
215
|
+
"creative": {"id": "creative_123456789"}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
mock_creative_details = {
|
|
219
|
+
"id": "creative_123456789",
|
|
220
|
+
"name": "Test Creative"
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Mock get_ad_creatives response with only thumbnail_url
|
|
224
|
+
mock_get_ad_creatives_response = json.dumps({
|
|
225
|
+
"data": [
|
|
226
|
+
{
|
|
227
|
+
"id": "creative_123456789",
|
|
228
|
+
"name": "Test Creative",
|
|
229
|
+
"status": "ACTIVE",
|
|
230
|
+
"thumbnail_url": "https://example.com/thumbnail_only.jpg" # Only option
|
|
231
|
+
}
|
|
232
|
+
]
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
# Mock PIL Image processing
|
|
236
|
+
mock_pil_image = MagicMock()
|
|
237
|
+
mock_pil_image.mode = "RGB"
|
|
238
|
+
mock_pil_image.convert.return_value = mock_pil_image
|
|
239
|
+
|
|
240
|
+
mock_byte_stream = MagicMock()
|
|
241
|
+
mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
|
|
242
|
+
|
|
243
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
|
|
244
|
+
patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
|
|
245
|
+
patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
|
|
246
|
+
patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
|
|
247
|
+
patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
|
|
248
|
+
|
|
249
|
+
mock_api.side_effect = [mock_ad_data, mock_creative_details]
|
|
250
|
+
mock_get_creatives.return_value = mock_get_ad_creatives_response
|
|
251
|
+
mock_download.return_value = b"fake_image_bytes"
|
|
252
|
+
mock_pil_open.return_value = mock_pil_image
|
|
253
|
+
mock_bytesio.return_value = mock_byte_stream
|
|
254
|
+
|
|
255
|
+
# This should use thumbnail_url as last resort
|
|
256
|
+
result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
|
|
257
|
+
|
|
258
|
+
# Verify it used thumbnail_url
|
|
259
|
+
assert result is not None
|
|
260
|
+
mock_download.assert_called_once_with("https://example.com/thumbnail_only.jpg")
|
|
261
|
+
|
|
262
|
+
def test_extract_creative_image_urls_prioritizes_quality(self):
|
|
263
|
+
"""Test that extract_creative_image_urls correctly prioritizes image quality."""
|
|
264
|
+
|
|
265
|
+
# Test creative with multiple image URLs
|
|
266
|
+
test_creative = {
|
|
267
|
+
"id": "creative_123456789",
|
|
268
|
+
"name": "Test Creative",
|
|
269
|
+
"thumbnail_url": "https://example.com/thumbnail.jpg", # Lowest priority
|
|
270
|
+
"image_url": "https://example.com/image.jpg", # Medium priority
|
|
271
|
+
"image_urls_for_viewing": [
|
|
272
|
+
"https://example.com/high_quality_1.jpg", # Highest priority
|
|
273
|
+
"https://example.com/high_quality_2.jpg"
|
|
274
|
+
],
|
|
275
|
+
"object_story_spec": {
|
|
276
|
+
"link_data": {
|
|
277
|
+
"picture": "https://example.com/object_story_picture.jpg" # High priority
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# Extract URLs
|
|
283
|
+
urls = extract_creative_image_urls(test_creative)
|
|
284
|
+
|
|
285
|
+
# Verify correct priority order
|
|
286
|
+
assert len(urls) >= 4
|
|
287
|
+
assert urls[0] == "https://example.com/high_quality_1.jpg" # First priority
|
|
288
|
+
assert urls[1] == "https://example.com/high_quality_2.jpg" # Second priority
|
|
289
|
+
assert "https://example.com/image.jpg" in urls # Medium priority
|
|
290
|
+
assert "https://example.com/object_story_picture.jpg" in urls # High priority
|
|
291
|
+
assert urls[-1] == "https://example.com/thumbnail.jpg" # Last priority
|
|
292
|
+
|
|
293
|
+
def test_extract_creative_image_urls_handles_missing_fields(self):
|
|
294
|
+
"""Test that extract_creative_image_urls handles missing fields gracefully."""
|
|
295
|
+
|
|
296
|
+
# Test creative with minimal fields
|
|
297
|
+
test_creative = {
|
|
298
|
+
"id": "creative_123456789",
|
|
299
|
+
"name": "Minimal Creative",
|
|
300
|
+
"thumbnail_url": "https://example.com/thumbnail.jpg"
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Extract URLs
|
|
304
|
+
urls = extract_creative_image_urls(test_creative)
|
|
305
|
+
|
|
306
|
+
# Should still work with only thumbnail_url
|
|
307
|
+
assert len(urls) == 1
|
|
308
|
+
assert urls[0] == "https://example.com/thumbnail.jpg"
|
|
309
|
+
|
|
310
|
+
def test_extract_creative_image_urls_removes_duplicates(self):
|
|
311
|
+
"""Test that extract_creative_image_urls removes duplicate URLs."""
|
|
312
|
+
|
|
313
|
+
# Test creative with duplicate URLs
|
|
314
|
+
test_creative = {
|
|
315
|
+
"id": "creative_123456789",
|
|
316
|
+
"name": "Duplicate URLs Creative",
|
|
317
|
+
"thumbnail_url": "https://example.com/same_url.jpg",
|
|
318
|
+
"image_url": "https://example.com/same_url.jpg", # Duplicate
|
|
319
|
+
"image_urls_for_viewing": [
|
|
320
|
+
"https://example.com/same_url.jpg", # Duplicate
|
|
321
|
+
"https://example.com/unique_url.jpg"
|
|
322
|
+
]
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# Extract URLs
|
|
326
|
+
urls = extract_creative_image_urls(test_creative)
|
|
327
|
+
|
|
328
|
+
# Should remove duplicates while preserving order
|
|
329
|
+
assert len(urls) == 2
|
|
330
|
+
assert urls[0] == "https://example.com/same_url.jpg" # First occurrence
|
|
331
|
+
assert urls[1] == "https://example.com/unique_url.jpg"
|
|
332
|
+
|
|
333
|
+
@pytest.mark.asyncio
|
|
334
|
+
async def test_get_ad_image_with_real_world_example(self):
|
|
335
|
+
"""Test with a real-world example that mimics the actual API response structure."""
|
|
336
|
+
|
|
337
|
+
# Mock responses based on real API data
|
|
338
|
+
mock_ad_data = {
|
|
339
|
+
"account_id": "act_15975950",
|
|
340
|
+
"creative": {"id": "606995022142818"}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
mock_creative_details = {
|
|
344
|
+
"id": "606995022142818",
|
|
345
|
+
"name": "Test Creative"
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
# Mock get_ad_creatives response based on real data
|
|
349
|
+
mock_get_ad_creatives_response = json.dumps({
|
|
350
|
+
"data": [
|
|
351
|
+
{
|
|
352
|
+
"id": "606995022142818",
|
|
353
|
+
"name": "Test Creative",
|
|
354
|
+
"status": "ACTIVE",
|
|
355
|
+
"thumbnail_url": "https://external.fbsb6-1.fna.fbcdn.net/emg1/v/t13/13476424677788553381?url=https%3A%2F%2Fwww.facebook.com%2Fads%2Fimage%2F%3Fd%3DAQLuJ5l4AROBvIUchp4g4JXxIT5uAZiAsgHQkD8Iw7BeVtkXNUUfs3leWpqQplJCJdixVIg3mq9KichJ64eRfM-r8aY4GtVQp8TvS_HBByJ8fGg_Cs7Kb8YkN4IDwJ4iQIIkMx30LycCKzuYtp9M-vOk&fb_obo=1&utld=facebook.com&stp=c0.5000x0.5000f_dst-emg0_p64x64_q75_tt6&edm=AEuWsiQEAAAA&_nc_gid=_QBCRbZxDq-i1ZiGEXxW2w&_nc_eui2=AeEbQXzmAdoqWLIXjuTDJ0xAoThZu47BlQqhOFm7jsGVCloP48Ep6Y_qIA5tcqrcSDff5f_k8xGzFIpD7PnUws8c&_nc_oc=Adn3GeYlXxbfEeY0wCBSgNdlwO80wXt5R5bgY2NozdroZ6CRSaXIaOSjVSK9S1LsqsY4GL_0dVzU80RY8QMucEkZ&ccb=13-1&oh=06_Q3-1AcBKUD0rfLGATAveIM5hMSWG9c7DsJzq2arvOl8W4Bpn&oe=688C87B2&_nc_sid=58080a",
|
|
356
|
+
"image_url": "https://scontent.fbsb6-1.fna.fbcdn.net/v/t45.1600-4/518574136_1116014047008737_2492837958169838537_n.png?stp=dst-jpg_tt6&_nc_cat=109&ccb=1-7&_nc_sid=890911&_nc_eui2=AeHbHqoiAUgF0QeX-tvUoDjYeTyJad_QEPF5PIlp39AQ8dP8cvOlHwiJjny8AUv7xxAlYyy5BGCqFU_oVM9CI7ln&_nc_ohc=VTTYlMOAWZoQ7kNvwGjLMW5&_nc_oc=AdnYDrpNrLovWZC_RG4tvoICGPjBNfzNJimhx-4SKW4BU2i_yzL00dX0-OiYEYokq394g8xR-1a-OuVDAm4HsSJy&_nc_zt=1&_nc_ht=scontent.fbsb6-1.fna&edm=AEuWsiQEAAAA&_nc_gid=_QBCRbZxDq-i1ZiGEXxW2w&oh=00_AfTujKmF365FnGgcokkkdWnK-vmnzQK8Icvlk0kB8SKM3g&oe=68906FC4",
|
|
357
|
+
"image_urls_for_viewing": [
|
|
358
|
+
"https://scontent.fbsb6-1.fna.fbcdn.net/v/t45.1600-4/518574136_1116014047008737_2492837958169838537_n.png?stp=dst-jpg_tt6&_nc_cat=109&ccb=1-7&_nc_sid=890911&_nc_eui2=AeHbHqoiAUgF0QeX-tvUoDjYeTyJad_QEPF5PIlp39AQ8dP8cvOlHwiJjny8AUv7xxAlYyy5BGCqFU_oVM9CI7ln&_nc_ohc=VTTYlMOAWZoQ7kNvwGjLMW5&_nc_oc=AdnYDrpNrLovWZC_RG4tvoICGPjBNfzNJimhx-4SKW4BU2i_yzL00dX0-OiYEYokq394g8xR-1a-OuVDAm4HsSJy&_nc_zt=1&_nc_ht=scontent.fbsb6-1.fna&edm=AEuWsiQEAAAA&_nc_gid=_QBCRbZxDq-i1ZiGEXxW2w&oh=00_AfTujKmF365FnGgcokkkdWnK-vmnzQK8Icvlk0kB8SKM3g&oe=68906FC4",
|
|
359
|
+
"https://external.fbsb6-1.fna.fbcdn.net/emg1/v/t13/13476424677788553381?url=https%3A%2F%2Fwww.facebook.com%2Fads%2Fimage%2F%3Fd%3DAQLuJ5l4AROBvIUchp4g4JXxIT5uAZiAsgHQkD8Iw7BeVtkXNUUfs3leWpqQplJCJdixVIg3mq9KichJ64eRfM-r8aY4GtVQp8TvS_HBByJ8fGg_Cs7Kb8YkN4IDwJ4iQIIkMx30LycCKzuYtp9M-vOk&fb_obo=1&utld=facebook.com&stp=c0.5000x0.5000f_dst-emg0_p64x64_q75_tt6&edm=AEuWsiQEAAAA&_nc_gid=_QBCRbZxDq-i1ZiGEXxW2w&_nc_eui2=AeEbQXzmAdoqWLIXjuTDJ0xAoThZu47BlQqhOFm7jsGVCloP48Ep6Y_qIA5tcqrcSDff5f_k8xGzFIpD7PnUws8c&_nc_oc=Adn3GeYlXxbfEeY0wCBSgNdlwO80wXt5R5bgY2NozdroZ6CRSaXIaOSjVSK9S1LsqsY4GL_0dVzU80RY8QMucEkZ&ccb=13-1&oh=06_Q3-1AcBKUD0rfLGATAveIM5hMSWG9c7DsJzq2arvOl8W4Bpn&oe=688C87B2&_nc_sid=58080a"
|
|
360
|
+
]
|
|
361
|
+
}
|
|
362
|
+
]
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
# Mock PIL Image processing
|
|
366
|
+
mock_pil_image = MagicMock()
|
|
367
|
+
mock_pil_image.mode = "RGB"
|
|
368
|
+
mock_pil_image.convert.return_value = mock_pil_image
|
|
369
|
+
|
|
370
|
+
mock_byte_stream = MagicMock()
|
|
371
|
+
mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
|
|
372
|
+
|
|
373
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
|
|
374
|
+
patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
|
|
375
|
+
patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
|
|
376
|
+
patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
|
|
377
|
+
patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
|
|
378
|
+
|
|
379
|
+
mock_api.side_effect = [mock_ad_data, mock_creative_details]
|
|
380
|
+
mock_get_creatives.return_value = mock_get_ad_creatives_response
|
|
381
|
+
mock_download.return_value = b"fake_image_bytes"
|
|
382
|
+
mock_pil_open.return_value = mock_pil_image
|
|
383
|
+
mock_bytesio.return_value = mock_byte_stream
|
|
384
|
+
|
|
385
|
+
# This should use the first image_urls_for_viewing URL (high quality)
|
|
386
|
+
result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
|
|
387
|
+
|
|
388
|
+
# Verify it used the high-quality URL (not the thumbnail)
|
|
389
|
+
assert result is not None
|
|
390
|
+
expected_url = "https://scontent.fbsb6-1.fna.fbcdn.net/v/t45.1600-4/518574136_1116014047008737_2492837958169838537_n.png?stp=dst-jpg_tt6&_nc_cat=109&ccb=1-7&_nc_sid=890911&_nc_eui2=AeHbHqoiAUgF0QeX-tvUoDjYeTyJad_QEPF5PIlp39AQ8dP8cvOlHwiJjny8AUv7xxAlYyy5BGCqFU_oVM9CI7ln&_nc_ohc=VTTYlMOAWZoQ7kNvwGjLMW5&_nc_oc=AdnYDrpNrLovWZC_RG4tvoICGPjBNfzNJimhx-4SKW4BU2i_yzL00dX0-OiYEYokq394g8xR-1a-OuVDAm4HsSJy&_nc_zt=1&_nc_ht=scontent.fbsb6-1.fna&edm=AEuWsiQEAAAA&_nc_gid=_QBCRbZxDq-i1ZiGEXxW2w&oh=00_AfTujKmF365FnGgcokkkdWnK-vmnzQK8Icvlk0kB8SKM3g&oe=68906FC4"
|
|
391
|
+
mock_download.assert_called_once_with(expected_url)
|
|
@@ -10,7 +10,12 @@ Tests for multiple issues that were fixed:
|
|
|
10
10
|
- Many modern creatives don't have image_hash but have direct URLs
|
|
11
11
|
- Fixed by adding direct URL fallback using image_urls_for_viewing and thumbnail_url
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
3. Image Quality Issue: Function was returning profile thumbnails instead of ad creative images
|
|
14
|
+
- Fixed by prioritizing image_urls_for_viewing over thumbnail_url
|
|
15
|
+
- Added proper fallback hierarchy: image_urls_for_viewing > image_url > object_story_spec.picture > thumbnail_url
|
|
16
|
+
|
|
17
|
+
The fixes enable get_ad_image to work with both traditional hash-based and modern URL-based creatives,
|
|
18
|
+
and ensure high-quality images are returned instead of thumbnails.
|
|
14
19
|
"""
|
|
15
20
|
|
|
16
21
|
import pytest
|
|
@@ -379,4 +384,71 @@ class TestGetAdImageRegressionFix:
|
|
|
379
384
|
|
|
380
385
|
# Should get error about download failure
|
|
381
386
|
assert isinstance(result, str)
|
|
382
|
-
assert "Failed to download image from direct URL" in result
|
|
387
|
+
assert "Failed to download image from direct URL" in result
|
|
388
|
+
|
|
389
|
+
async def test_get_ad_image_quality_improvement_prioritizes_high_quality(self):
|
|
390
|
+
"""Test that the image quality improvement correctly prioritizes high-quality images over thumbnails."""
|
|
391
|
+
|
|
392
|
+
# Mock responses for creative with both high-quality and thumbnail URLs
|
|
393
|
+
mock_ad_data = {
|
|
394
|
+
"account_id": "act_123456789",
|
|
395
|
+
"creative": {"id": "creative_123456789"}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
mock_creative_details = {
|
|
399
|
+
"id": "creative_123456789",
|
|
400
|
+
"name": "Quality Test Creative"
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# Mock get_ad_creatives response with both URLs
|
|
404
|
+
mock_get_ad_creatives_response = json.dumps({
|
|
405
|
+
"data": [
|
|
406
|
+
{
|
|
407
|
+
"id": "creative_123456789",
|
|
408
|
+
"name": "Quality Test Creative",
|
|
409
|
+
"status": "ACTIVE",
|
|
410
|
+
"thumbnail_url": "https://example.com/thumbnail_64x64.jpg", # Low quality thumbnail
|
|
411
|
+
"image_url": "https://example.com/full_image.jpg", # Medium quality
|
|
412
|
+
"image_urls_for_viewing": [
|
|
413
|
+
"https://example.com/high_quality_image.jpg", # Highest quality
|
|
414
|
+
"https://example.com/alt_high_quality.jpg"
|
|
415
|
+
],
|
|
416
|
+
"object_story_spec": {
|
|
417
|
+
"link_data": {
|
|
418
|
+
"picture": "https://example.com/object_story_picture.jpg"
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
]
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
# Mock PIL Image processing
|
|
426
|
+
mock_pil_image = MagicMock()
|
|
427
|
+
mock_pil_image.mode = "RGB"
|
|
428
|
+
mock_pil_image.convert.return_value = mock_pil_image
|
|
429
|
+
|
|
430
|
+
mock_byte_stream = MagicMock()
|
|
431
|
+
mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
|
|
432
|
+
|
|
433
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
|
|
434
|
+
patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
|
|
435
|
+
patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
|
|
436
|
+
patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
|
|
437
|
+
patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
|
|
438
|
+
|
|
439
|
+
mock_api.side_effect = [mock_ad_data, mock_creative_details]
|
|
440
|
+
mock_get_creatives.return_value = mock_get_ad_creatives_response
|
|
441
|
+
mock_download.return_value = b"fake_image_bytes"
|
|
442
|
+
mock_pil_open.return_value = mock_pil_image
|
|
443
|
+
mock_bytesio.return_value = mock_byte_stream
|
|
444
|
+
|
|
445
|
+
# This should prioritize image_urls_for_viewing[0] over thumbnail_url
|
|
446
|
+
result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
|
|
447
|
+
|
|
448
|
+
# Verify it used the highest quality URL, not the thumbnail
|
|
449
|
+
assert result is not None
|
|
450
|
+
mock_download.assert_called_once_with("https://example.com/high_quality_image.jpg")
|
|
451
|
+
|
|
452
|
+
# Verify it did NOT use the thumbnail URL
|
|
453
|
+
# Check that the call was made with the high-quality URL, not the thumbnail
|
|
454
|
+
mock_download.assert_called_once_with("https://example.com/high_quality_image.jpg")
|
meta_ads_mcp-0.7.8/RELEASE.md
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
# Release Process
|
|
2
|
-
|
|
3
|
-
This repository uses GitHub Actions to automatically publish releases to PyPI. Here's how it works:
|
|
4
|
-
|
|
5
|
-
## Automated Publishing
|
|
6
|
-
|
|
7
|
-
### Setup Status
|
|
8
|
-
|
|
9
|
-
✅ **Trusted Publishing Configured**: The repository is already set up with PyPI trusted publishing using the `release` environment.
|
|
10
|
-
|
|
11
|
-
### Creating a Release
|
|
12
|
-
|
|
13
|
-
1. **Update the version** in both files:
|
|
14
|
-
|
|
15
|
-
In `pyproject.toml`:
|
|
16
|
-
```toml
|
|
17
|
-
version = "0.3.8" # Increment as needed
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
In `meta_ads_mcp/__init__.py`:
|
|
21
|
-
```python
|
|
22
|
-
__version__ = "0.3.8" # Must match pyproject.toml
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
2. **Commit and push** the version changes:
|
|
26
|
-
```bash
|
|
27
|
-
git add pyproject.toml meta_ads_mcp/__init__.py
|
|
28
|
-
git commit -m "Bump version to 0.3.8"
|
|
29
|
-
git push origin main
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
3. **Wait for build tests to pass** (optional):
|
|
33
|
-
```bash
|
|
34
|
-
# Check the latest test workflow run
|
|
35
|
-
gh run list --workflow=test.yml --limit 1
|
|
36
|
-
|
|
37
|
-
# Get the run ID and wait for completion
|
|
38
|
-
RUN_ID=$(gh run list --workflow=test.yml --limit 1 --json databaseId --jq '.[0].databaseId')
|
|
39
|
-
gh run watch $RUN_ID
|
|
40
|
-
```
|
|
41
|
-
Note: This only tests package building and installation, not the actual pytest tests.
|
|
42
|
-
|
|
43
|
-
4. **Create a GitHub release**:
|
|
44
|
-
```bash
|
|
45
|
-
gh release create 0.3.8 --title "0.3.8" --generate-notes
|
|
46
|
-
```
|
|
47
|
-
This command will:
|
|
48
|
-
- Create a release with the specified version (no "v" prefix)
|
|
49
|
-
- Auto-generate release notes from commits
|
|
50
|
-
- Automatically trigger the GitHub Action for PyPI publishing
|
|
51
|
-
|
|
52
|
-
5. **Automatic deployment**:
|
|
53
|
-
- The GitHub Action will automatically trigger
|
|
54
|
-
- It will build the package and publish to PyPI
|
|
55
|
-
- Check the "Actions" tab to monitor progress
|
|
56
|
-
|
|
57
|
-
## Workflows
|
|
58
|
-
|
|
59
|
-
### `publish.yml`
|
|
60
|
-
- **Triggers**: When a GitHub release is published, or manual workflow dispatch
|
|
61
|
-
- **Purpose**: Builds and publishes the package to PyPI
|
|
62
|
-
- **Security**: Uses trusted publishing with OIDC tokens (no API keys needed)
|
|
63
|
-
|
|
64
|
-
### `test.yml`
|
|
65
|
-
- **Triggers**: On pushes and pull requests to main/master
|
|
66
|
-
- **Purpose**: Tests package building and installation across Python versions
|
|
67
|
-
- **Matrix**: Tests Python 3.10, 3.11, and 3.12
|
|
68
|
-
- **Note**: Does not run pytest tests, only validates package structure
|
|
69
|
-
|
|
70
|
-
## Manual Deployment
|
|
71
|
-
|
|
72
|
-
If you need to deploy manually:
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
# Install build tools
|
|
76
|
-
pip install build twine
|
|
77
|
-
|
|
78
|
-
# Build the package
|
|
79
|
-
python -m build
|
|
80
|
-
|
|
81
|
-
# Upload to PyPI (requires API token or configured credentials)
|
|
82
|
-
python -m twine upload dist/*
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
## Version Management
|
|
86
|
-
|
|
87
|
-
- Follow semantic versioning (SemVer): `MAJOR.MINOR.PATCH`
|
|
88
|
-
- **Important**: Update version in BOTH `pyproject.toml` and `meta_ads_mcp/__init__.py`
|
|
89
|
-
- The git tag should match the version (e.g., `v0.3.8` for version `0.3.8`)
|
|
90
|
-
- Keep versions synchronized between the two files
|
|
91
|
-
|
|
92
|
-
## Security Notes
|
|
93
|
-
|
|
94
|
-
- Trusted publishing is preferred over API tokens
|
|
95
|
-
- Uses GitHub's OIDC tokens for secure authentication to PyPI
|
|
96
|
-
- Only maintainers should be able to create releases
|
|
97
|
-
- All builds run in isolated GitHub-hosted runners
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|