adloop 0.32.0__tar.gz → 0.33.0__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.
- {adloop-0.32.0 → adloop-0.33.0}/PKG-INFO +1 -1
- {adloop-0.32.0 → adloop-0.33.0}/pyproject.toml +1 -1
- {adloop-0.32.0 → adloop-0.33.0}/pyproject.toml.orig +1 -1
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/conversion_actions.py +97 -25
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/write.py +2 -2
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/merchant/read.py +11 -1
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/adloop.md +15 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/server.py +65 -22
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/upload_card.html +26 -5
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/uploads.py +132 -34
- {adloop-0.32.0 → adloop-0.33.0}/README.md +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/__init__.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/__main__.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/auth.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/cli.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/config.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/crossref.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/runtime.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.32.0 → adloop-0.33.0}/src/adloop/tracking.py +0 -0
|
@@ -25,6 +25,7 @@ import hashlib
|
|
|
25
25
|
import math
|
|
26
26
|
import re
|
|
27
27
|
import threading
|
|
28
|
+
from dataclasses import dataclass
|
|
28
29
|
from datetime import datetime, timezone as _tz
|
|
29
30
|
from typing import TYPE_CHECKING, Callable
|
|
30
31
|
|
|
@@ -1009,8 +1010,16 @@ _CSV_COMMENT_PREFIX = "#"
|
|
|
1009
1010
|
_MAX_CSV_BYTES = 5 * 1024 * 1024
|
|
1010
1011
|
|
|
1011
1012
|
|
|
1013
|
+
@dataclass(frozen=True)
|
|
1014
|
+
class UploadedCsv:
|
|
1015
|
+
"""A conversion CSV that arrived as an upload instead of a local path."""
|
|
1016
|
+
|
|
1017
|
+
name: str
|
|
1018
|
+
data: bytes
|
|
1019
|
+
|
|
1020
|
+
|
|
1012
1021
|
def _read_upload_csv(
|
|
1013
|
-
csv_path: str,
|
|
1022
|
+
csv_path: "str | UploadedCsv",
|
|
1014
1023
|
) -> tuple[list[tuple[int, list[str]]], list[str], str]:
|
|
1015
1024
|
"""Read an upload CSV into ``(source_line, cells)`` records (header first).
|
|
1016
1025
|
|
|
@@ -1020,8 +1029,9 @@ def _read_upload_csv(
|
|
|
1020
1029
|
Counting records instead would drift as soon as a comment or a bad row is
|
|
1021
1030
|
dropped.
|
|
1022
1031
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1032
|
+
``csv_path`` is a path on the machine running AdLoop (refused in server
|
|
1033
|
+
mode before it gets here) or an :class:`UploadedCsv` from the upload
|
|
1034
|
+
store, which a hosted server reads instead. Errors name the file
|
|
1025
1035
|
and the schema, never file *content* — a hosted runtime can read files the
|
|
1026
1036
|
caller is not allowed to see.
|
|
1027
1037
|
"""
|
|
@@ -1036,15 +1046,21 @@ def _read_upload_csv(
|
|
|
1036
1046
|
# which is what timestamps without an offset are resolved against.
|
|
1037
1047
|
timezone = ""
|
|
1038
1048
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1049
|
+
import io
|
|
1050
|
+
|
|
1051
|
+
if isinstance(csv_path, UploadedCsv):
|
|
1052
|
+
path = None
|
|
1053
|
+
size = len(csv_path.data)
|
|
1054
|
+
else:
|
|
1055
|
+
path = Path(csv_path).expanduser()
|
|
1056
|
+
if not path.is_file():
|
|
1057
|
+
return [], [f"CSV not found or not a regular file: {path}"], ""
|
|
1058
|
+
if path.suffix.lower() != ".csv":
|
|
1059
|
+
return [], [f"CSV must be a .csv file, got: {path.name}"], ""
|
|
1060
|
+
try:
|
|
1061
|
+
size = path.stat().st_size
|
|
1062
|
+
except OSError as exc:
|
|
1063
|
+
return [], [f"CSV could not be read: {exc.strerror or exc}"], ""
|
|
1048
1064
|
if size > _MAX_CSV_BYTES:
|
|
1049
1065
|
return [], [
|
|
1050
1066
|
f"CSV is {size / 1_048_576:.1f} MB; the limit is "
|
|
@@ -1053,7 +1069,11 @@ def _read_upload_csv(
|
|
|
1053
1069
|
], ""
|
|
1054
1070
|
|
|
1055
1071
|
try:
|
|
1056
|
-
with
|
|
1072
|
+
with (
|
|
1073
|
+
io.StringIO(csv_path.data.decode("utf-8-sig"), newline="")
|
|
1074
|
+
if path is None
|
|
1075
|
+
else path.open("r", newline="", encoding="utf-8-sig")
|
|
1076
|
+
) as handle:
|
|
1057
1077
|
position = {"line": 0}
|
|
1058
1078
|
|
|
1059
1079
|
def _lines():
|
|
@@ -1164,7 +1184,7 @@ def _pad_to_header(raw: list[str], columns: int) -> list[str]:
|
|
|
1164
1184
|
|
|
1165
1185
|
|
|
1166
1186
|
def _parse_call_conversion_csv(
|
|
1167
|
-
csv_path: str, default_region: str = ""
|
|
1187
|
+
csv_path: "str | UploadedCsv", default_region: str = ""
|
|
1168
1188
|
) -> tuple[list[dict], list[str], list[str], list[dict]]:
|
|
1169
1189
|
"""Read the call-conversions CSV (local file) and normalize each row.
|
|
1170
1190
|
|
|
@@ -1310,7 +1330,8 @@ def draft_upload_call_conversions(
|
|
|
1310
1330
|
config: AdLoopConfig,
|
|
1311
1331
|
*,
|
|
1312
1332
|
customer_id: str = "",
|
|
1313
|
-
csv_path: str,
|
|
1333
|
+
csv_path: str = "",
|
|
1334
|
+
upload_id: str = "",
|
|
1314
1335
|
default_region: str = "",
|
|
1315
1336
|
consent: dict | None = None,
|
|
1316
1337
|
) -> dict:
|
|
@@ -1353,12 +1374,17 @@ def draft_upload_call_conversions(
|
|
|
1353
1374
|
operation="upload_call_conversions",
|
|
1354
1375
|
default_region=default_region,
|
|
1355
1376
|
consent=consent,
|
|
1377
|
+
csv_path=csv_path,
|
|
1378
|
+
upload_id=upload_id,
|
|
1356
1379
|
)
|
|
1380
|
+
if error:
|
|
1381
|
+
return error
|
|
1382
|
+
source, error = _csv_source(csv_path, upload_id)
|
|
1357
1383
|
if error:
|
|
1358
1384
|
return error
|
|
1359
1385
|
|
|
1360
1386
|
rows, parse_advisories, skipped, error = _upload_parse_result(
|
|
1361
|
-
*_parse_call_conversion_csv(
|
|
1387
|
+
*_parse_call_conversion_csv(source, default_region),
|
|
1362
1388
|
empty_message="CSV contained zero conversion rows",
|
|
1363
1389
|
)
|
|
1364
1390
|
if error:
|
|
@@ -1493,7 +1519,7 @@ def draft_upload_call_conversions(
|
|
|
1493
1519
|
apply_only_payload={"rows": frozen_rows},
|
|
1494
1520
|
)
|
|
1495
1521
|
store_plan(plan)
|
|
1496
|
-
return plan.to_preview()
|
|
1522
|
+
return _consume_upload(plan.to_preview(), upload_id)
|
|
1497
1523
|
|
|
1498
1524
|
|
|
1499
1525
|
def _resolve_upload_action(
|
|
@@ -1714,6 +1740,8 @@ def _upload_draft_preflight(
|
|
|
1714
1740
|
operation: str,
|
|
1715
1741
|
default_region: str,
|
|
1716
1742
|
consent: dict | None,
|
|
1743
|
+
csv_path: str = "",
|
|
1744
|
+
upload_id: str = "",
|
|
1717
1745
|
) -> tuple[str, dict | None, dict | None]:
|
|
1718
1746
|
"""The guards both upload drafts run before they touch the CSV.
|
|
1719
1747
|
|
|
@@ -1724,12 +1752,19 @@ def _upload_draft_preflight(
|
|
|
1724
1752
|
from adloop.runtime import deployment_mode
|
|
1725
1753
|
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
1726
1754
|
|
|
1727
|
-
if
|
|
1755
|
+
if bool(csv_path) == bool(upload_id):
|
|
1756
|
+
return "", None, {
|
|
1757
|
+
"error": (
|
|
1758
|
+
"Pass exactly one of csv_path (a file on the machine running "
|
|
1759
|
+
"AdLoop) or upload_id (a CSV uploaded with upload_conversion_csv)."
|
|
1760
|
+
)
|
|
1761
|
+
}
|
|
1762
|
+
if csv_path and deployment_mode() == "server":
|
|
1728
1763
|
return "", None, {
|
|
1729
1764
|
"error": (
|
|
1730
|
-
"
|
|
1731
|
-
"
|
|
1732
|
-
"
|
|
1765
|
+
"csv_path names a file on the machine running AdLoop, which on "
|
|
1766
|
+
"the hosted server is not yours. Upload the CSV with "
|
|
1767
|
+
"upload_conversion_csv and pass its upload_id instead."
|
|
1733
1768
|
)
|
|
1734
1769
|
}
|
|
1735
1770
|
|
|
@@ -1754,6 +1789,37 @@ def _upload_draft_preflight(
|
|
|
1754
1789
|
return region, consent_norm, None
|
|
1755
1790
|
|
|
1756
1791
|
|
|
1792
|
+
def _csv_source(csv_path: str, upload_id: str) -> "tuple[str | UploadedCsv | None, dict | None]":
|
|
1793
|
+
"""The CSV to parse: the local path, or the uploaded file's bytes."""
|
|
1794
|
+
if not upload_id:
|
|
1795
|
+
return csv_path, None
|
|
1796
|
+
from adloop.uploads import UploadError, current_tenant, get_upload_store
|
|
1797
|
+
|
|
1798
|
+
try:
|
|
1799
|
+
upload, data = get_upload_store().get(current_tenant(), upload_id, kind="csv")
|
|
1800
|
+
except UploadError as exc:
|
|
1801
|
+
return None, {"error": f"{exc}. Upload the CSV again with upload_conversion_csv."}
|
|
1802
|
+
return UploadedCsv(name=upload.name, data=data), None
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def _consume_upload(preview: dict, upload_id: str) -> dict:
|
|
1806
|
+
"""Delete an uploaded CSV once its plan holds the rows.
|
|
1807
|
+
|
|
1808
|
+
The plan carries everything apply needs, so the raw file (phone numbers,
|
|
1809
|
+
emails) has no reason to stay. A draft that failed keeps the file, so a
|
|
1810
|
+
retry with another default_region needs no new upload.
|
|
1811
|
+
"""
|
|
1812
|
+
if upload_id:
|
|
1813
|
+
from adloop.uploads import current_tenant, get_upload_store
|
|
1814
|
+
|
|
1815
|
+
get_upload_store().delete(current_tenant(), upload_id)
|
|
1816
|
+
preview["source_upload"] = (
|
|
1817
|
+
f"{upload_id} was read and deleted; the plan holds the rows. A new "
|
|
1818
|
+
"draft needs a new upload."
|
|
1819
|
+
)
|
|
1820
|
+
return preview
|
|
1821
|
+
|
|
1822
|
+
|
|
1757
1823
|
def _upload_parse_result(
|
|
1758
1824
|
rows: list[dict],
|
|
1759
1825
|
errors: list[str],
|
|
@@ -2262,7 +2328,7 @@ _OPTIONAL_EC_HEADERS = [
|
|
|
2262
2328
|
|
|
2263
2329
|
|
|
2264
2330
|
def _parse_ec_for_leads_csv(
|
|
2265
|
-
csv_path: str, default_region: str = ""
|
|
2331
|
+
csv_path: "str | UploadedCsv", default_region: str = ""
|
|
2266
2332
|
) -> tuple[list[dict], list[str], list[str], list[dict]]:
|
|
2267
2333
|
"""Parse the EC-for-Leads CSV (local file) and hash PII at parse time.
|
|
2268
2334
|
|
|
@@ -2463,7 +2529,8 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2463
2529
|
config: AdLoopConfig,
|
|
2464
2530
|
*,
|
|
2465
2531
|
customer_id: str = "",
|
|
2466
|
-
csv_path: str,
|
|
2532
|
+
csv_path: str = "",
|
|
2533
|
+
upload_id: str = "",
|
|
2467
2534
|
default_region: str = "",
|
|
2468
2535
|
consent: dict | None = None,
|
|
2469
2536
|
) -> dict:
|
|
@@ -2501,12 +2568,17 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2501
2568
|
operation="upload_enhanced_conversions_for_leads",
|
|
2502
2569
|
default_region=default_region,
|
|
2503
2570
|
consent=consent,
|
|
2571
|
+
csv_path=csv_path,
|
|
2572
|
+
upload_id=upload_id,
|
|
2504
2573
|
)
|
|
2574
|
+
if error:
|
|
2575
|
+
return error
|
|
2576
|
+
source, error = _csv_source(csv_path, upload_id)
|
|
2505
2577
|
if error:
|
|
2506
2578
|
return error
|
|
2507
2579
|
|
|
2508
2580
|
rows, parse_advisories, skipped, error = _upload_parse_result(
|
|
2509
|
-
*_parse_ec_for_leads_csv(
|
|
2581
|
+
*_parse_ec_for_leads_csv(source, default_region),
|
|
2510
2582
|
empty_message="CSV contained zero conversion rows",
|
|
2511
2583
|
)
|
|
2512
2584
|
if error:
|
|
@@ -2735,7 +2807,7 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2735
2807
|
apply_only_payload={"rows": frozen_rows},
|
|
2736
2808
|
)
|
|
2737
2809
|
store_plan(plan)
|
|
2738
|
-
return plan.to_preview()
|
|
2810
|
+
return _consume_upload(plan.to_preview(), upload_id)
|
|
2739
2811
|
|
|
2740
2812
|
|
|
2741
2813
|
def _apply_upload_enhanced_conversions_for_leads(
|
|
@@ -3351,7 +3351,7 @@ def image_payloads(
|
|
|
3351
3351
|
from adloop.uploads import UploadError, current_tenant, get_upload_store
|
|
3352
3352
|
|
|
3353
3353
|
try:
|
|
3354
|
-
upload, _data = get_upload_store().get(current_tenant(), upload_id)
|
|
3354
|
+
upload, _data = get_upload_store().get(current_tenant(), upload_id, kind="image")
|
|
3355
3355
|
except UploadError as exc:
|
|
3356
3356
|
errors.append(f"Upload {index + 1}: {exc}")
|
|
3357
3357
|
continue
|
|
@@ -5135,7 +5135,7 @@ def load_image_bytes(payload: dict) -> tuple[bytes, PurePath]:
|
|
|
5135
5135
|
if payload.get("upload_id"):
|
|
5136
5136
|
from adloop.uploads import current_tenant, get_upload_store
|
|
5137
5137
|
|
|
5138
|
-
upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]))
|
|
5138
|
+
upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]), kind="image")
|
|
5139
5139
|
if upload.sha256 != payload.get("sha256"):
|
|
5140
5140
|
raise ValueError(f"upload {upload.upload_id} changed since the preview; draft again.")
|
|
5141
5141
|
return image_bytes, PurePosixPath(upload.name)
|
|
@@ -216,7 +216,17 @@ def get_merchant_feed_health(
|
|
|
216
216
|
f"severity — CRITICAL means offers stop serving entirely: "
|
|
217
217
|
+ "; ".join(i["title"] for i in critical[:3])
|
|
218
218
|
)
|
|
219
|
-
if not
|
|
219
|
+
if not serving_total:
|
|
220
|
+
# Zero products is not a healthy feed: Shopping and Performance Max
|
|
221
|
+
# have nothing to advertise.
|
|
222
|
+
insights.append(
|
|
223
|
+
"The account has no products at all (0 approved, pending or "
|
|
224
|
+
"disapproved), so Shopping and Performance Max campaigns have "
|
|
225
|
+
"nothing to show. Either no feed or data source is set up yet, "
|
|
226
|
+
"products were added in the last ~30 minutes (status lags), or "
|
|
227
|
+
"the products live in another Merchant Center account."
|
|
228
|
+
)
|
|
229
|
+
elif not account_issues and not totals["disapproved"]:
|
|
220
230
|
insights.append("No disapprovals or account issues — feed is healthy.")
|
|
221
231
|
|
|
222
232
|
return {
|
|
@@ -249,6 +249,21 @@ For images that have no public URL: a file on the user's computer, or an image t
|
|
|
249
249
|
- **Using an upload:** `draft_image_assets(upload_ids=["up_..."])` for Google Ads; `"upload:up_..."` as `image_url`, `thumbnail_url` or a carousel card's `image_url` in `draft_reddit_ad` (AdLoop Cloud only, because Reddit downloads the image from a short-lived public link). Videos cannot be uploaded.
|
|
250
250
|
- Uploads expire after 24 hours. Self-hosted without a card, pass local files directly (`image_paths`).
|
|
251
251
|
|
|
252
|
+
### Offline Conversion Uploads
|
|
253
|
+
|
|
254
|
+
Conversions that happen offline (a sales call, a lead that turned into a deal) go back to Google Ads from a CSV in Google's upload template.
|
|
255
|
+
|
|
256
|
+
| Tool | When to Use | Key Parameters |
|
|
257
|
+
|------|-------------|----------------|
|
|
258
|
+
| `upload_conversion_csv` | The CSV is not on the machine running AdLoop (always on AdLoop Cloud). Shows the upload card, or returns an upload link | (none) |
|
|
259
|
+
| `draft_upload_call_conversions` | Calls from call assets or call-tracking numbers that became a conversion; matched by the caller's phone number | exactly one of `csv_path` (local file) or `upload_id`; `default_region` for numbers without a country code; `consent` |
|
|
260
|
+
| `draft_upload_enhanced_conversions_for_leads` | Form leads that became a conversion; matched by hashed email, phone or name and address | exactly one of `csv_path` or `upload_id`; `default_region`; `consent` |
|
|
261
|
+
|
|
262
|
+
- The conversion action named in the CSV's `Conversion Name` column must exist and be of type UPLOAD_CALLS (calls) or UPLOAD_CLICKS (leads); the draft checks it against the account.
|
|
263
|
+
- **The CSV holds personal data** (phone numbers, emails, names). An uploaded file lasts two hours at most and is deleted as soon as a draft has read it, so a second draft needs a new upload; a failed draft keeps it for a retry (for example with `default_region`). Lead identifiers are hashed while drafting. Never ask the user to paste rows into the chat.
|
|
264
|
+
- EEA traffic needs consent signals: pass `consent` or fill the CSV's `Ad User Data` / `Ad Personalization` columns. Present the preview's `consent_summary`, `skipped_rows` and `dedup_warnings`.
|
|
265
|
+
- Apply sends batches of 2,000 rows. `PARTIAL_UPLOAD` means some batches are already in the account: the plan is retired, and a new draft continues at `resume_from_line`. Don't re-upload rows that went through.
|
|
266
|
+
|
|
252
267
|
### Planning Tools
|
|
253
268
|
|
|
254
269
|
| Tool | When to Use | Key Parameters |
|
|
@@ -4590,10 +4590,15 @@ def upload_images() -> dict:
|
|
|
4590
4590
|
draft_image_assets, or as "upload:<id>" into the image fields of
|
|
4591
4591
|
draft_reddit_ad. Returns the current uploads and how to add more.
|
|
4592
4592
|
"""
|
|
4593
|
+
return _upload_tool_result("image")
|
|
4594
|
+
|
|
4595
|
+
|
|
4596
|
+
def _upload_tool_result(kind: str):
|
|
4597
|
+
"""The upload card (with its code) or the upload link, for ``kind``."""
|
|
4593
4598
|
from adloop.uploads import upload_instructions
|
|
4594
4599
|
|
|
4595
4600
|
shows_cards = client_shows_cards()
|
|
4596
|
-
result = upload_instructions(shows_cards)
|
|
4601
|
+
result = upload_instructions(shows_cards, kind)
|
|
4597
4602
|
if not shows_cards:
|
|
4598
4603
|
return result
|
|
4599
4604
|
from fastmcp.tools import ToolResult
|
|
@@ -4609,6 +4614,29 @@ def upload_images() -> dict:
|
|
|
4609
4614
|
)
|
|
4610
4615
|
|
|
4611
4616
|
|
|
4617
|
+
@_tool(
|
|
4618
|
+
title="Upload a conversion CSV",
|
|
4619
|
+
annotations=_READONLY,
|
|
4620
|
+
tags={"ads"},
|
|
4621
|
+
meta={"ui": {"resourceUri": UPLOAD_CARD_URI}},
|
|
4622
|
+
)
|
|
4623
|
+
@_safe
|
|
4624
|
+
def upload_conversion_csv() -> dict:
|
|
4625
|
+
"""Let the user upload a conversion CSV for an offline conversion upload.
|
|
4626
|
+
|
|
4627
|
+
For the CSV behind draft_upload_call_conversions or
|
|
4628
|
+
draft_upload_enhanced_conversions_for_leads when the file is not on the
|
|
4629
|
+
machine running AdLoop, as on AdLoop Cloud. Apps that show AdLoop's
|
|
4630
|
+
cards display an upload card where the user drops the .csv file; the
|
|
4631
|
+
card then posts the upload id into the chat. Other apps on AdLoop Cloud
|
|
4632
|
+
get an upload link for the user (or a coding agent, with curl). The
|
|
4633
|
+
file holds personal data (phone numbers, emails), so it is kept for two
|
|
4634
|
+
hours at most and deleted as soon as a draft has read it. Returns the
|
|
4635
|
+
CSV uploads waiting to be used and how to add one.
|
|
4636
|
+
"""
|
|
4637
|
+
return _upload_tool_result("csv")
|
|
4638
|
+
|
|
4639
|
+
|
|
4612
4640
|
@_tool(
|
|
4613
4641
|
title="Store an uploaded image",
|
|
4614
4642
|
annotations=_WRITE,
|
|
@@ -4616,16 +4644,19 @@ def upload_images() -> dict:
|
|
|
4616
4644
|
meta={"ui": {"resourceUri": UPLOAD_CARD_URI, "visibility": ["app"]}},
|
|
4617
4645
|
)
|
|
4618
4646
|
@_safe
|
|
4619
|
-
def store_image_upload(name: str, data_base64: str, code: str = "") -> dict:
|
|
4620
|
-
"""Store
|
|
4647
|
+
def store_image_upload(name: str, data_base64: str, code: str = "", kind: str = "image") -> dict:
|
|
4648
|
+
"""Store a file dropped on the AdLoop upload card.
|
|
4621
4649
|
|
|
4622
4650
|
Called only by the upload card; AI apps keep this tool out of the
|
|
4623
|
-
model's tool list. Returns the upload's id, type and dimensions
|
|
4651
|
+
model's tool list. Returns the upload's id, type and size (dimensions
|
|
4652
|
+
for images, lines for CSVs).
|
|
4624
4653
|
|
|
4625
4654
|
Args:
|
|
4626
4655
|
name: The file name, used for the asset name.
|
|
4627
4656
|
data_base64: The file content, base64-encoded.
|
|
4628
|
-
code: The upload card's code from upload_images
|
|
4657
|
+
code: The upload card's code from upload_images or
|
|
4658
|
+
upload_conversion_csv.
|
|
4659
|
+
kind: "image" (default) or "csv" for a conversion CSV.
|
|
4629
4660
|
"""
|
|
4630
4661
|
import base64
|
|
4631
4662
|
import binascii
|
|
@@ -4637,20 +4668,21 @@ def store_image_upload(name: str, data_base64: str, code: str = "") -> dict:
|
|
|
4637
4668
|
except (binascii.Error, ValueError):
|
|
4638
4669
|
return {"error": "The file did not arrive intact; upload it again."}
|
|
4639
4670
|
try:
|
|
4640
|
-
upload = get_upload_store().put(current_tenant(), name, data)
|
|
4671
|
+
upload = get_upload_store().put(current_tenant(), name, data, kind=kind)
|
|
4641
4672
|
except UploadError as exc:
|
|
4642
4673
|
return {"error": f"{name}: {exc}"}
|
|
4643
4674
|
return {"status": "UPLOADED", **upload.summary()}
|
|
4644
4675
|
|
|
4645
4676
|
|
|
4646
|
-
@_tool(title="List
|
|
4677
|
+
@_tool(title="List uploads", annotations=_READONLY, tags={"core"})
|
|
4647
4678
|
@_safe
|
|
4648
4679
|
def list_uploads() -> dict:
|
|
4649
|
-
"""List the
|
|
4680
|
+
"""List the files the user uploaded to AdLoop that have not expired.
|
|
4650
4681
|
|
|
4651
|
-
Returns each upload's upload_id, name, type,
|
|
4652
|
-
|
|
4653
|
-
|
|
4682
|
+
Returns each upload's upload_id, kind (image or csv), name, type, size,
|
|
4683
|
+
dimensions (images) or lines (CSVs) and hours until it expires: images
|
|
4684
|
+
last a day, conversion CSVs two hours or until a draft reads them. The
|
|
4685
|
+
ids of files the user sent through an upload link show up here.
|
|
4654
4686
|
"""
|
|
4655
4687
|
from adloop.uploads import current_tenant, get_upload_store
|
|
4656
4688
|
|
|
@@ -4660,7 +4692,8 @@ def list_uploads() -> dict:
|
|
|
4660
4692
|
"usage": (
|
|
4661
4693
|
"draft_image_assets(upload_ids=[...]) for Google Ads; "
|
|
4662
4694
|
"\"upload:<upload_id>\" as image_url, thumbnail_url or a carousel card's "
|
|
4663
|
-
"image_url in draft_reddit_ad (AdLoop Cloud only)."
|
|
4695
|
+
"image_url in draft_reddit_ad (AdLoop Cloud only). CSV uploads: upload_id in "
|
|
4696
|
+
"draft_upload_call_conversions or draft_upload_enhanced_conversions_for_leads."
|
|
4664
4697
|
),
|
|
4665
4698
|
}
|
|
4666
4699
|
|
|
@@ -6141,7 +6174,8 @@ def draft_remove_conversion_action(
|
|
|
6141
6174
|
@_tool(title="Draft call conversion upload", annotations=_WRITE, tags={"ads"})
|
|
6142
6175
|
@_safe
|
|
6143
6176
|
def draft_upload_call_conversions(
|
|
6144
|
-
csv_path: str,
|
|
6177
|
+
csv_path: str = "",
|
|
6178
|
+
upload_id: str = "",
|
|
6145
6179
|
default_region: str = "",
|
|
6146
6180
|
consent: dict | None = None,
|
|
6147
6181
|
customer_id: str = "",
|
|
@@ -6157,8 +6191,9 @@ def draft_upload_call_conversions(
|
|
|
6157
6191
|
drafting, so a typo fails here rather than after the upload. Optional
|
|
6158
6192
|
`Ad User Data` and `Ad Personalization` columns carry consent per row.
|
|
6159
6193
|
|
|
6160
|
-
|
|
6161
|
-
|
|
6194
|
+
The CSV comes from csv_path (a file on the machine running AdLoop) or
|
|
6195
|
+
from upload_id (a file uploaded with upload_conversion_csv, the only way
|
|
6196
|
+
on AdLoop Cloud). An uploaded file is deleted once the draft has read it.
|
|
6162
6197
|
|
|
6163
6198
|
PII: the caller phone number is required RAW by Google (it cannot be
|
|
6164
6199
|
hashed), so it lives in the plan's apply-only payload — the preview shows
|
|
@@ -6181,8 +6216,10 @@ def draft_upload_call_conversions(
|
|
|
6181
6216
|
|
|
6182
6217
|
Args:
|
|
6183
6218
|
csv_path: Path of the CSV file holding the upload rows, read on the
|
|
6184
|
-
machine that runs AdLoop.
|
|
6185
|
-
|
|
6219
|
+
machine that runs AdLoop. Refused on the hosted server; use
|
|
6220
|
+
upload_id there.
|
|
6221
|
+
upload_id: Id of a CSV uploaded with upload_conversion_csv (up_...),
|
|
6222
|
+
instead of csv_path.
|
|
6186
6223
|
default_region: ISO 3166-1 alpha-2 country assumed for phone numbers
|
|
6187
6224
|
that carry no country code, e.g. "DE" for "0151 12345678". Empty
|
|
6188
6225
|
leaves such numbers unusable and the row is skipped.
|
|
@@ -6202,6 +6239,7 @@ def draft_upload_call_conversions(
|
|
|
6202
6239
|
current_config(),
|
|
6203
6240
|
customer_id=customer_id or current_config().ads.customer_id,
|
|
6204
6241
|
csv_path=csv_path,
|
|
6242
|
+
upload_id=upload_id,
|
|
6205
6243
|
default_region=default_region,
|
|
6206
6244
|
consent=consent,
|
|
6207
6245
|
)
|
|
@@ -6210,7 +6248,8 @@ def draft_upload_call_conversions(
|
|
|
6210
6248
|
@_tool(title="Draft enhanced conversions upload", annotations=_WRITE, tags={"ads"})
|
|
6211
6249
|
@_safe
|
|
6212
6250
|
def draft_upload_enhanced_conversions_for_leads(
|
|
6213
|
-
csv_path: str,
|
|
6251
|
+
csv_path: str = "",
|
|
6252
|
+
upload_id: str = "",
|
|
6214
6253
|
default_region: str = "",
|
|
6215
6254
|
consent: dict | None = None,
|
|
6216
6255
|
customer_id: str = "",
|
|
@@ -6235,8 +6274,9 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
6235
6274
|
sharing an Order ID count once at Google; two rows with different Order
|
|
6236
6275
|
IDs are two real orders and are not reported.
|
|
6237
6276
|
|
|
6238
|
-
|
|
6239
|
-
|
|
6277
|
+
The CSV comes from csv_path (a file on the machine running AdLoop) or
|
|
6278
|
+
from upload_id (a file uploaded with upload_conversion_csv, the only way
|
|
6279
|
+
on AdLoop Cloud). An uploaded file is deleted once the draft has read it.
|
|
6240
6280
|
|
|
6241
6281
|
Returns a preview with a plan_id, the row counts, the consent the rows
|
|
6242
6282
|
actually carry (`consent_summary`) and the skipped rows; nothing is
|
|
@@ -6244,8 +6284,10 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
6244
6284
|
|
|
6245
6285
|
Args:
|
|
6246
6286
|
csv_path: Path of the CSV file holding the upload rows, read on the
|
|
6247
|
-
machine that runs AdLoop.
|
|
6248
|
-
|
|
6287
|
+
machine that runs AdLoop. Refused on the hosted server; use
|
|
6288
|
+
upload_id there.
|
|
6289
|
+
upload_id: Id of a CSV uploaded with upload_conversion_csv (up_...),
|
|
6290
|
+
instead of csv_path.
|
|
6249
6291
|
default_region: ISO 3166-1 alpha-2 country assumed for phone numbers
|
|
6250
6292
|
that carry no country code, e.g. "DE" for "0151 12345678". Empty
|
|
6251
6293
|
leaves such numbers unusable; the row still uploads when its email
|
|
@@ -6266,6 +6308,7 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
6266
6308
|
current_config(),
|
|
6267
6309
|
customer_id=customer_id or current_config().ads.customer_id,
|
|
6268
6310
|
csv_path=csv_path,
|
|
6311
|
+
upload_id=upload_id,
|
|
6269
6312
|
default_region=default_region,
|
|
6270
6313
|
consent=consent,
|
|
6271
6314
|
)
|
|
@@ -90,6 +90,11 @@
|
|
|
90
90
|
drop: "Drop images here", or: "or", pick: "Choose files",
|
|
91
91
|
uploading: "Uploading…", done: "Uploaded", tooBig: "Over 5 MB", wrongType: "Not PNG, JPEG or GIF",
|
|
92
92
|
message: "I uploaded images to AdLoop:\n{list}\nUse these upload ids for the ad.",
|
|
93
|
+
brandCsv: "AdLoop · conversion CSV", titleCsv: "Upload your conversion CSV",
|
|
94
|
+
subCsv: "A .csv file in Google's upload template, up to 5 MB. It is deleted once AdLoop has read it, after two hours at the latest.",
|
|
95
|
+
dropCsv: "Drop the CSV here", pickCsv: "Choose file", wrongTypeCsv: "Not a .csv file",
|
|
96
|
+
messageCsv: "I uploaded a conversion CSV to AdLoop:\n{list}\nDraft the conversion upload with this upload_id.",
|
|
97
|
+
lines: "lines",
|
|
93
98
|
},
|
|
94
99
|
de: {
|
|
95
100
|
brand: "AdLoop · Bilder hochladen", title: "Bilder für deine Anzeigen hochladen",
|
|
@@ -97,16 +102,30 @@
|
|
|
97
102
|
drop: "Bilder hierher ziehen", or: "oder", pick: "Dateien auswählen",
|
|
98
103
|
uploading: "Wird hochgeladen…", done: "Hochgeladen", tooBig: "Größer als 5 MB", wrongType: "Kein PNG, JPEG oder GIF",
|
|
99
104
|
message: "Ich habe Bilder bei AdLoop hochgeladen:\n{list}\nNutze diese Upload-IDs für die Anzeige.",
|
|
105
|
+
brandCsv: "AdLoop · Conversion-CSV", titleCsv: "Conversion-CSV hochladen",
|
|
106
|
+
subCsv: "Eine .csv-Datei in Googles Upload-Vorlage, bis 5 MB. Sie wird gelöscht, sobald AdLoop sie gelesen hat, spätestens nach zwei Stunden.",
|
|
107
|
+
dropCsv: "CSV hierher ziehen", pickCsv: "Datei auswählen", wrongTypeCsv: "Keine .csv-Datei",
|
|
108
|
+
messageCsv: "Ich habe eine Conversion-CSV bei AdLoop hochgeladen:\n{list}\nErstelle den Conversion-Upload mit dieser upload_id.",
|
|
109
|
+
lines: "Zeilen",
|
|
100
110
|
},
|
|
101
111
|
};
|
|
102
112
|
|
|
103
113
|
let lang = "en";
|
|
114
|
+
// "image" or "csv": upload_images and upload_conversion_csv share this card.
|
|
115
|
+
let kind = "image";
|
|
104
116
|
document.documentElement.dataset.theme =
|
|
105
117
|
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
106
118
|
let nextId = 1;
|
|
107
119
|
const pending = new Map();
|
|
108
120
|
const $ = (id) => document.getElementById(id);
|
|
109
|
-
const
|
|
121
|
+
const pick = (key) => (STRINGS[lang] || STRINGS.en)[key] || STRINGS.en[key];
|
|
122
|
+
const t = (key) => (kind === "csv" && pick(key + "Csv")) || pick(key) || key;
|
|
123
|
+
const accepted = (file) => kind === "csv" ? /\.csv$/i.test(file.name) : TYPES.includes(file.type);
|
|
124
|
+
function render() {
|
|
125
|
+
for (const el of document.querySelectorAll("[data-t]")) el.textContent = t(el.dataset.t);
|
|
126
|
+
$("file").accept = kind === "csv" ? ".csv,text/csv" : TYPES.join(",");
|
|
127
|
+
$("file").multiple = kind !== "csv";
|
|
128
|
+
}
|
|
110
129
|
|
|
111
130
|
function post(message) { window.parent.postMessage(message, "*"); }
|
|
112
131
|
function request(method, params) {
|
|
@@ -126,7 +145,7 @@
|
|
|
126
145
|
const vars = ctx.styles && ctx.styles.variables;
|
|
127
146
|
if (vars) for (const [k, v] of Object.entries(vars)) if (v) document.documentElement.style.setProperty(k, v);
|
|
128
147
|
if (typeof ctx.locale === "string" && ctx.locale.toLowerCase().startsWith("de")) lang = "de";
|
|
129
|
-
|
|
148
|
+
render();
|
|
130
149
|
}
|
|
131
150
|
|
|
132
151
|
function payloadOf(result) {
|
|
@@ -152,6 +171,7 @@
|
|
|
152
171
|
}
|
|
153
172
|
|
|
154
173
|
function describe(u) {
|
|
174
|
+
if (u.kind === "csv") return u.upload_id + " · " + u.lines + " " + t("lines") + " · " + u.size_kb + " KB";
|
|
155
175
|
return u.upload_id + " · " + u.width + "×" + u.height + " · " + u.size_kb + " KB";
|
|
156
176
|
}
|
|
157
177
|
|
|
@@ -167,12 +187,12 @@
|
|
|
167
187
|
async function upload(files) {
|
|
168
188
|
const stored = [];
|
|
169
189
|
for (const file of files) {
|
|
170
|
-
const row = addRow(file.name,
|
|
171
|
-
if (!
|
|
190
|
+
const row = addRow(file.name, kind === "image" && accepted(file) ? URL.createObjectURL(file) : "");
|
|
191
|
+
if (!accepted(file)) { row.state.textContent = t("wrongType"); row.state.classList.add("bad"); continue; }
|
|
172
192
|
if (file.size > MAX_BYTES) { row.state.textContent = t("tooBig"); row.state.classList.add("bad"); continue; }
|
|
173
193
|
row.state.textContent = t("uploading");
|
|
174
194
|
try {
|
|
175
|
-
const result = await request("tools/call", { name: TOOL, arguments: { name: file.name, data_base64: await readBase64(file), code } });
|
|
195
|
+
const result = await request("tools/call", { name: TOOL, arguments: { name: file.name, data_base64: await readBase64(file), code, kind } });
|
|
176
196
|
const r = payloadOf(result);
|
|
177
197
|
if ((result && result.isError) || r.error || !r.upload_id) throw new Error(r.error || "upload failed");
|
|
178
198
|
row.meta.textContent = describe(r);
|
|
@@ -209,6 +229,7 @@
|
|
|
209
229
|
if (shownExisting || !result) return;
|
|
210
230
|
shownExisting = true;
|
|
211
231
|
const r = payloadOf(result);
|
|
232
|
+
if (r.upload_kind === "csv") { kind = "csv"; render(); }
|
|
212
233
|
for (const u of r.uploads || []) {
|
|
213
234
|
const row = addRow(u.name, "");
|
|
214
235
|
row.meta.textContent = describe(u);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Uploads: files a user hands to AdLoop without a public URL or a local path.
|
|
2
2
|
|
|
3
3
|
AI apps cannot pass a file from the chat to a tool, so an upload reaches
|
|
4
4
|
AdLoop on its own path: the upload card (MCP Apps) calls the app-only
|
|
@@ -8,7 +8,13 @@ opens a one-time upload link. Either way the image lands in an
|
|
|
8
8
|
``draft_image_assets(upload_ids=[...])`` for Google Ads, ``upload:<id>`` in
|
|
9
9
|
the media fields of ``draft_reddit_ad``.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Conversion CSVs take the same path (``upload_conversion_csv``) and go into
|
|
12
|
+
the ``upload_id`` of the conversion upload drafts. They hold personal data
|
|
13
|
+
(phone numbers, emails), so they live two hours at most and are deleted as
|
|
14
|
+
soon as a draft has read them; a hosted store also encrypts every file at
|
|
15
|
+
rest (:class:`LocalUploadStore` ``cipher``).
|
|
16
|
+
|
|
17
|
+
Uploads are short-lived: images expire after a day and exist to be turned
|
|
12
18
|
into ad assets, not to be kept. Self-hosted, the store is a folder under
|
|
13
19
|
``~/.adloop/uploads``. A hosted runtime sets its own store and the links
|
|
14
20
|
(:func:`set_upload_links`): the upload page and the short-lived public file
|
|
@@ -32,6 +38,9 @@ UPLOAD_CARD_URI = "ui://adloop/upload-card.html"
|
|
|
32
38
|
STORE_UPLOAD_TOOL = "store_image_upload"
|
|
33
39
|
MAX_UPLOAD_BYTES = 5120 * 1024 # Google's image asset limit; Reddit takes more
|
|
34
40
|
DEFAULT_TTL_SECONDS = 24 * 3600
|
|
41
|
+
# Conversion CSVs carry personal data; they are read once by a draft.
|
|
42
|
+
CSV_TTL_SECONDS = 2 * 3600
|
|
43
|
+
KINDS = ("image", "csv")
|
|
35
44
|
MAX_FILES_PER_TENANT = 50
|
|
36
45
|
MAX_BYTES_PER_TENANT = 100 * 1024 * 1024
|
|
37
46
|
_ID_PATTERN = re.compile(r"^up_[A-Za-z0-9_-]{8,40}$")
|
|
@@ -48,17 +57,26 @@ class Upload:
|
|
|
48
57
|
sha256: str
|
|
49
58
|
created_at: float
|
|
50
59
|
expires_at: float
|
|
60
|
+
# Stored metadata from before CSV uploads has no kind: those are images.
|
|
61
|
+
kind: str = "image"
|
|
62
|
+
# Data rows of a CSV (lines that are not the header, blank or comments
|
|
63
|
+
# are not told apart here; the draft counts precisely).
|
|
64
|
+
lines: int = 0
|
|
51
65
|
|
|
52
66
|
def summary(self) -> dict:
|
|
53
|
-
|
|
67
|
+
out = {
|
|
54
68
|
"upload_id": self.upload_id,
|
|
69
|
+
"kind": self.kind,
|
|
55
70
|
"name": self.name,
|
|
56
71
|
"mime_type": self.mime_type,
|
|
57
|
-
"width": self.width,
|
|
58
|
-
"height": self.height,
|
|
59
72
|
"size_kb": round(self.size_bytes / 1024, 1),
|
|
60
73
|
"expires_in_hours": max(0, round((self.expires_at - time.time()) / 3600, 1)),
|
|
61
74
|
}
|
|
75
|
+
if self.kind == "csv":
|
|
76
|
+
out["lines"] = self.lines
|
|
77
|
+
else:
|
|
78
|
+
out["width"], out["height"] = self.width, self.height
|
|
79
|
+
return out
|
|
62
80
|
|
|
63
81
|
|
|
64
82
|
class UploadError(ValueError):
|
|
@@ -66,8 +84,8 @@ class UploadError(ValueError):
|
|
|
66
84
|
|
|
67
85
|
|
|
68
86
|
class UploadLinks(Protocol):
|
|
69
|
-
def upload_page(self, tenant: str) -> tuple[str, int]:
|
|
70
|
-
"""A URL where the user uploads files for ``tenant``, and its lifetime in seconds."""
|
|
87
|
+
def upload_page(self, tenant: str, kind: str = "image") -> tuple[str, int]:
|
|
88
|
+
"""A URL where the user uploads files of ``kind`` for ``tenant``, and its lifetime in seconds."""
|
|
71
89
|
|
|
72
90
|
def public_file(self, tenant: str, upload_id: str) -> str:
|
|
73
91
|
"""A short-lived public URL serving the upload (for Reddit to download)."""
|
|
@@ -95,10 +113,45 @@ def inspect_image(data: bytes) -> tuple[str, int, int]:
|
|
|
95
113
|
raise UploadError(f"not a usable image: {exc} (PNG, JPEG or GIF)") from exc
|
|
96
114
|
|
|
97
115
|
|
|
116
|
+
def inspect_csv(name: str, data: bytes) -> int:
|
|
117
|
+
"""Line count of a conversion CSV; raises UploadError for anything else.
|
|
118
|
+
|
|
119
|
+
Only the container is checked here (a .csv name, UTF-8 text, the size).
|
|
120
|
+
The columns are the draft's business: it names every problem with its
|
|
121
|
+
line, which a refusal at upload time could not.
|
|
122
|
+
"""
|
|
123
|
+
if not data:
|
|
124
|
+
raise UploadError("the file is empty")
|
|
125
|
+
if len(data) > MAX_UPLOAD_BYTES:
|
|
126
|
+
raise UploadError(
|
|
127
|
+
f"the file is {len(data) / 1024:.0f} KB; CSV uploads can be at most "
|
|
128
|
+
f"{MAX_UPLOAD_BYTES // 1024} KB (Google takes 2,000 rows per request anyway)"
|
|
129
|
+
)
|
|
130
|
+
if not str(name or "").lower().endswith(".csv"):
|
|
131
|
+
raise UploadError("conversion uploads must be .csv files (Google's upload template)")
|
|
132
|
+
try:
|
|
133
|
+
text = data.decode("utf-8-sig")
|
|
134
|
+
except UnicodeDecodeError as exc:
|
|
135
|
+
raise UploadError("the CSV is not UTF-8 text; export it as CSV UTF-8") from exc
|
|
136
|
+
if "\x00" in text:
|
|
137
|
+
raise UploadError("the file is not a text CSV")
|
|
138
|
+
return sum(1 for line in text.splitlines() if line.strip())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class UploadCipher(Protocol):
|
|
142
|
+
"""Encryption at rest for a hosted store (the file bytes, not the metadata)."""
|
|
143
|
+
|
|
144
|
+
def encrypt(self, data: bytes) -> bytes: ...
|
|
145
|
+
|
|
146
|
+
def decrypt(self, data: bytes) -> bytes: ...
|
|
147
|
+
|
|
148
|
+
|
|
98
149
|
class LocalUploadStore:
|
|
99
150
|
"""Uploads as files in a folder, one subfolder per tenant.
|
|
100
151
|
|
|
101
152
|
Expired files are removed whenever the store is used; there is no timer.
|
|
153
|
+
With a ``cipher`` every file is stored encrypted (hosted runtimes);
|
|
154
|
+
self-hosted, the files sit on the user's own machine as they are.
|
|
102
155
|
"""
|
|
103
156
|
|
|
104
157
|
def __init__(
|
|
@@ -106,13 +159,17 @@ class LocalUploadStore:
|
|
|
106
159
|
root: str | Path,
|
|
107
160
|
*,
|
|
108
161
|
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
162
|
+
csv_ttl_seconds: int = CSV_TTL_SECONDS,
|
|
109
163
|
max_files: int = MAX_FILES_PER_TENANT,
|
|
110
164
|
max_bytes: int = MAX_BYTES_PER_TENANT,
|
|
165
|
+
cipher: UploadCipher | None = None,
|
|
111
166
|
) -> None:
|
|
112
167
|
self.root = Path(root)
|
|
113
168
|
self.ttl_seconds = ttl_seconds
|
|
169
|
+
self.csv_ttl_seconds = csv_ttl_seconds
|
|
114
170
|
self.max_files = max_files
|
|
115
171
|
self.max_bytes = max_bytes
|
|
172
|
+
self.cipher = cipher
|
|
116
173
|
|
|
117
174
|
def _dir(self, tenant: str) -> Path:
|
|
118
175
|
key = hashlib.sha256(str(tenant or "local").encode()).hexdigest()[:24]
|
|
@@ -143,13 +200,20 @@ class LocalUploadStore:
|
|
|
143
200
|
except FileNotFoundError:
|
|
144
201
|
pass
|
|
145
202
|
|
|
146
|
-
def put(self, tenant: str, name: str, data: bytes) -> Upload:
|
|
147
|
-
|
|
203
|
+
def put(self, tenant: str, name: str, data: bytes, *, kind: str = "image") -> Upload:
|
|
204
|
+
if kind not in KINDS:
|
|
205
|
+
raise UploadError(f"unknown upload kind '{kind}'")
|
|
206
|
+
lines = 0
|
|
207
|
+
if kind == "csv":
|
|
208
|
+
lines = inspect_csv(name, data)
|
|
209
|
+
mime_type, width, height = "text/csv", 0, 0
|
|
210
|
+
else:
|
|
211
|
+
mime_type, width, height = inspect_image(data)
|
|
148
212
|
existing = self._entries(tenant)
|
|
149
213
|
digest = hashlib.sha256(data).hexdigest()
|
|
150
214
|
for upload in existing:
|
|
151
|
-
if upload.sha256 == digest:
|
|
152
|
-
return upload # the same
|
|
215
|
+
if upload.sha256 == digest and upload.kind == kind:
|
|
216
|
+
return upload # the same file twice is one upload
|
|
153
217
|
if len(existing) >= self.max_files:
|
|
154
218
|
raise UploadError(
|
|
155
219
|
f"this workspace already holds {len(existing)} uploads; they expire after a day"
|
|
@@ -169,30 +233,46 @@ class LocalUploadStore:
|
|
|
169
233
|
size_bytes=len(data),
|
|
170
234
|
sha256=digest,
|
|
171
235
|
created_at=now,
|
|
172
|
-
expires_at=now + self.ttl_seconds,
|
|
236
|
+
expires_at=now + (self.csv_ttl_seconds if kind == "csv" else self.ttl_seconds),
|
|
237
|
+
kind=kind,
|
|
238
|
+
lines=lines,
|
|
173
239
|
)
|
|
174
240
|
folder = self._dir(tenant)
|
|
175
241
|
folder.mkdir(parents=True, exist_ok=True)
|
|
176
|
-
|
|
242
|
+
stored = self.cipher.encrypt(data) if self.cipher else data
|
|
243
|
+
for suffix, payload in ((".bin", stored), (".json", json.dumps(asdict(upload)).encode())):
|
|
177
244
|
tmp = folder / f".{upload.upload_id}{suffix}.tmp"
|
|
178
245
|
tmp.write_bytes(payload)
|
|
179
246
|
os.replace(tmp, folder / f"{upload.upload_id}{suffix}")
|
|
180
247
|
return upload
|
|
181
248
|
|
|
182
|
-
def get(self, tenant: str, upload_id: str) -> tuple[Upload, bytes]:
|
|
249
|
+
def get(self, tenant: str, upload_id: str, *, kind: str | None = None) -> tuple[Upload, bytes]:
|
|
183
250
|
upload_id = str(upload_id or "").strip()
|
|
184
251
|
if not _ID_PATTERN.match(upload_id):
|
|
185
252
|
raise UploadError(f"'{upload_id}' is not an upload id (they look like up_...)")
|
|
186
253
|
for upload in self._entries(tenant):
|
|
187
254
|
if upload.upload_id == upload_id:
|
|
255
|
+
if kind and upload.kind != kind:
|
|
256
|
+
raise UploadError(
|
|
257
|
+
f"upload {upload_id} is a {upload.kind} upload, not a {kind} upload"
|
|
258
|
+
)
|
|
188
259
|
data = (self._dir(tenant) / f"{upload_id}.bin").read_bytes()
|
|
260
|
+
if self.cipher:
|
|
261
|
+
data = self.cipher.decrypt(data)
|
|
189
262
|
return upload, data
|
|
190
263
|
raise UploadError(
|
|
191
|
-
f"upload {upload_id} was not found; uploads expire after a day
|
|
264
|
+
f"upload {upload_id} was not found; uploads expire (images after a day, "
|
|
265
|
+
"CSVs after two hours or once a draft has read them; see list_uploads)"
|
|
192
266
|
)
|
|
193
267
|
|
|
194
|
-
def
|
|
195
|
-
|
|
268
|
+
def delete(self, tenant: str, upload_id: str) -> None:
|
|
269
|
+
"""Remove an upload now (a CSV once a draft has read it)."""
|
|
270
|
+
upload_id = str(upload_id or "").strip()
|
|
271
|
+
if _ID_PATTERN.match(upload_id):
|
|
272
|
+
self._remove(self._dir(tenant), upload_id)
|
|
273
|
+
|
|
274
|
+
def list(self, tenant: str, *, kind: str | None = None) -> list[Upload]:
|
|
275
|
+
return [u for u in self._entries(tenant) if kind is None or u.kind == kind]
|
|
196
276
|
|
|
197
277
|
|
|
198
278
|
_store: LocalUploadStore | None = None
|
|
@@ -243,42 +323,60 @@ def public_url_for(upload_id: str) -> str:
|
|
|
243
323
|
"upload: references need a public link Reddit can download, which only AdLoop "
|
|
244
324
|
"Cloud provides. Self-hosted, pass a public image URL instead."
|
|
245
325
|
)
|
|
246
|
-
get_upload_store().get(current_tenant(), upload_id) # exists
|
|
326
|
+
get_upload_store().get(current_tenant(), upload_id, kind="image") # exists, not expired
|
|
247
327
|
return links.public_file(current_tenant(), upload_id)
|
|
248
328
|
|
|
249
329
|
|
|
250
|
-
def upload_instructions(shows_cards: bool) -> dict:
|
|
251
|
-
"""What ``upload_images``
|
|
330
|
+
def upload_instructions(shows_cards: bool, kind: str = "image") -> dict:
|
|
331
|
+
"""What ``upload_images`` / ``upload_conversion_csv`` return: current
|
|
332
|
+
uploads of that kind plus the way to add more."""
|
|
252
333
|
tenant = current_tenant()
|
|
253
|
-
result: dict = {
|
|
254
|
-
|
|
255
|
-
"
|
|
256
|
-
|
|
257
|
-
|
|
334
|
+
result: dict = {
|
|
335
|
+
"upload_kind": kind,
|
|
336
|
+
"uploads": [u.summary() for u in get_upload_store().list(tenant, kind=kind)],
|
|
337
|
+
}
|
|
338
|
+
if kind == "csv":
|
|
339
|
+
what, example = "the CSV", 'curl -T "conversions.csv" "{url}/"'
|
|
340
|
+
use = (
|
|
341
|
+
"Pass the id as upload_id to draft_upload_call_conversions or "
|
|
342
|
+
"draft_upload_enhanced_conversions_for_leads. The file is deleted once a draft "
|
|
343
|
+
"has read it, and after two hours at the latest."
|
|
344
|
+
)
|
|
345
|
+
local = (
|
|
346
|
+
"This app shows no upload card. Self-hosted, pass the file directly: the "
|
|
347
|
+
"conversion upload drafts take csv_path, a file on this machine."
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
what, example = "the images", 'curl -T "logo.png" "{url}/"'
|
|
351
|
+
use = (
|
|
352
|
+
"Use the ids as upload_ids in draft_image_assets, or as \"upload:<upload_id>\" in "
|
|
353
|
+
"the image fields of draft_reddit_ad."
|
|
354
|
+
)
|
|
355
|
+
local = (
|
|
356
|
+
"This app shows no upload card. Self-hosted, pass the files directly: "
|
|
357
|
+
"draft_image_assets(image_paths=[...]) takes local files; Reddit ads need a "
|
|
358
|
+
"public image URL."
|
|
359
|
+
)
|
|
258
360
|
if shows_cards:
|
|
259
361
|
result["upload"] = "card"
|
|
260
362
|
result["instructions"] = (
|
|
261
|
-
"The user sees an AdLoop upload card and drops
|
|
363
|
+
f"The user sees an AdLoop upload card and drops {what} there. The card posts "
|
|
262
364
|
"the upload ids into the chat when it is done; wait for that. " + use
|
|
263
365
|
)
|
|
264
366
|
return result
|
|
265
367
|
links = get_upload_links()
|
|
266
368
|
if links is None:
|
|
267
|
-
result["instructions"] =
|
|
268
|
-
"This app shows no upload card. Self-hosted, pass the files directly: "
|
|
269
|
-
"draft_image_assets(image_paths=[...]) takes local files; Reddit ads need a "
|
|
270
|
-
"public image URL."
|
|
271
|
-
)
|
|
369
|
+
result["instructions"] = local
|
|
272
370
|
return result
|
|
273
|
-
url, lifetime = links.upload_page(tenant)
|
|
371
|
+
url, lifetime = links.upload_page(tenant, kind)
|
|
274
372
|
minutes = max(1, lifetime // 60)
|
|
275
373
|
result.update({
|
|
276
374
|
"upload_url": url,
|
|
277
375
|
"expires_in_minutes": minutes,
|
|
278
|
-
"curl_example":
|
|
376
|
+
"curl_example": example.format(url=url),
|
|
279
377
|
"instructions": (
|
|
280
378
|
f"Give the user this upload link (valid {minutes} minutes): they open it in the "
|
|
281
|
-
"browser and drop
|
|
379
|
+
f"browser and drop {what}, then tell you they are done; call list_uploads for "
|
|
282
380
|
"the ids. A coding agent with the files on disk can upload them itself with curl "
|
|
283
381
|
"(curl_example; one call per file). " + use
|
|
284
382
|
),
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|