matrx-social 0.1.1__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.
Files changed (124) hide show
  1. matrx_social-0.1.1/.gitignore +351 -0
  2. matrx_social-0.1.1/FEATURE.md +53 -0
  3. matrx_social-0.1.1/PKG-INFO +65 -0
  4. matrx_social-0.1.1/matrx_social/__init__.py +64 -0
  5. matrx_social-0.1.1/matrx_social/contracts.py +218 -0
  6. matrx_social-0.1.1/matrx_social/conversion.py +36 -0
  7. matrx_social-0.1.1/matrx_social/errors.py +110 -0
  8. matrx_social-0.1.1/matrx_social/fediverse.py +108 -0
  9. matrx_social-0.1.1/matrx_social/interface.py +77 -0
  10. matrx_social-0.1.1/matrx_social/liveness.py +178 -0
  11. matrx_social-0.1.1/matrx_social/profile_sites.py +236 -0
  12. matrx_social-0.1.1/matrx_social/providers/__init__.py +65 -0
  13. matrx_social-0.1.1/matrx_social/providers/brightdata/__init__.py +3 -0
  14. matrx_social-0.1.1/matrx_social/providers/brightdata/client.py +637 -0
  15. matrx_social-0.1.1/matrx_social/providers/brightdata/normalize.py +409 -0
  16. matrx_social-0.1.1/matrx_social/providers/grok_x/__init__.py +3 -0
  17. matrx_social-0.1.1/matrx_social/providers/grok_x/client.py +402 -0
  18. matrx_social-0.1.1/matrx_social/providers/mastodon_public/__init__.py +3 -0
  19. matrx_social-0.1.1/matrx_social/providers/mastodon_public/client.py +246 -0
  20. matrx_social-0.1.1/matrx_social/providers/scrapecreators/__init__.py +3 -0
  21. matrx_social-0.1.1/matrx_social/providers/scrapecreators/client.py +540 -0
  22. matrx_social-0.1.1/matrx_social/providers/scrapecreators/normalize.py +1227 -0
  23. matrx_social-0.1.1/matrx_social/transport.py +415 -0
  24. matrx_social-0.1.1/matrx_social/urls.py +493 -0
  25. matrx_social-0.1.1/pyproject.toml +36 -0
  26. matrx_social-0.1.1/scripts/day_one_battery.py +298 -0
  27. matrx_social-0.1.1/tests/__init__.py +0 -0
  28. matrx_social-0.1.1/tests/conftest.py +43 -0
  29. matrx_social-0.1.1/tests/fixtures/brightdata/facebook_pages.json +49 -0
  30. matrx_social-0.1.1/tests/fixtures/brightdata/instagram_comments.json +298 -0
  31. matrx_social-0.1.1/tests/fixtures/brightdata/instagram_posts.json +187 -0
  32. matrx_social-0.1.1/tests/fixtures/brightdata/instagram_profiles.json +247 -0
  33. matrx_social-0.1.1/tests/fixtures/brightdata/instagram_profiles_missing.json +10 -0
  34. matrx_social-0.1.1/tests/fixtures/brightdata/linkedin_companies.json +370 -0
  35. matrx_social-0.1.1/tests/fixtures/brightdata/linkedin_people.json +445 -0
  36. matrx_social-0.1.1/tests/fixtures/brightdata/linkedin_posts.json +183 -0
  37. matrx_social-0.1.1/tests/fixtures/brightdata/linkedin_posts_discover.json +1 -0
  38. matrx_social-0.1.1/tests/fixtures/brightdata/pinterest_profiles.json +82 -0
  39. matrx_social-0.1.1/tests/fixtures/brightdata/reddit_posts.json +321 -0
  40. matrx_social-0.1.1/tests/fixtures/brightdata/threads_profiles.json +55 -0
  41. matrx_social-0.1.1/tests/fixtures/brightdata/tiktok_posts.json +228 -0
  42. matrx_social-0.1.1/tests/fixtures/brightdata/tiktok_profiles.json +390 -0
  43. matrx_social-0.1.1/tests/fixtures/brightdata/x_posts.json +61 -0
  44. matrx_social-0.1.1/tests/fixtures/brightdata/x_posts_discover.json +1 -0
  45. matrx_social-0.1.1/tests/fixtures/brightdata/x_profiles.json +288 -0
  46. matrx_social-0.1.1/tests/fixtures/brightdata/youtube_channels.json +67 -0
  47. matrx_social-0.1.1/tests/fixtures/brightdata/youtube_videos.json +346 -0
  48. matrx_social-0.1.1/tests/fixtures/brightdata/youtube_videos_error_unavailable.json +12 -0
  49. matrx_social-0.1.1/tests/fixtures/fb_ads.json +1 -0
  50. matrx_social-0.1.1/tests/fixtures/fb_post.json +1 -0
  51. matrx_social-0.1.1/tests/fixtures/fb_posts.json +1 -0
  52. matrx_social-0.1.1/tests/fixtures/fb_profile.json +1 -0
  53. matrx_social-0.1.1/tests/fixtures/g_ads.json +1 -0
  54. matrx_social-0.1.1/tests/fixtures/grok_x_responses_nasa_latest.json +1 -0
  55. matrx_social-0.1.1/tests/fixtures/ig_comments.json +1 -0
  56. matrx_social-0.1.1/tests/fixtures/ig_post.json +1 -0
  57. matrx_social-0.1.1/tests/fixtures/ig_post_age_restricted.json +8 -0
  58. matrx_social-0.1.1/tests/fixtures/ig_posts.json +1 -0
  59. matrx_social-0.1.1/tests/fixtures/ig_profile.json +1 -0
  60. matrx_social-0.1.1/tests/fixtures/ig_profile_missing.json +1 -0
  61. matrx_social-0.1.1/tests/fixtures/ig_profile_not_found.json +1 -0
  62. matrx_social-0.1.1/tests/fixtures/ig_profile_restricted.json +1 -0
  63. matrx_social-0.1.1/tests/fixtures/ig_transcript.json +1 -0
  64. matrx_social-0.1.1/tests/fixtures/ig_user_posts_v2.json +1 -0
  65. matrx_social-0.1.1/tests/fixtures/li_ads.json +1 -0
  66. matrx_social-0.1.1/tests/fixtures/li_company.json +113 -0
  67. matrx_social-0.1.1/tests/fixtures/li_post.json +1 -0
  68. matrx_social-0.1.1/tests/fixtures/li_profile.json +1 -0
  69. matrx_social-0.1.1/tests/fixtures/pin_board.json +1 -0
  70. matrx_social-0.1.1/tests/fixtures/pin_not_found.json +1 -0
  71. matrx_social-0.1.1/tests/fixtures/pin_pin.json +1 -0
  72. matrx_social-0.1.1/tests/fixtures/pin_search.json +1 -0
  73. matrx_social-0.1.1/tests/fixtures/pin_user_boards.json +1 -0
  74. matrx_social-0.1.1/tests/fixtures/post_link_cases.json +523 -0
  75. matrx_social-0.1.1/tests/fixtures/profile_handle_cases.json +425 -0
  76. matrx_social-0.1.1/tests/fixtures/profile_site_cases.json +42 -0
  77. matrx_social-0.1.1/tests/fixtures/reddit_comments.json +1 -0
  78. matrx_social-0.1.1/tests/fixtures/reddit_post.json +1 -0
  79. matrx_social-0.1.1/tests/fixtures/reddit_search.json +1 -0
  80. matrx_social-0.1.1/tests/fixtures/reddit_subreddit.json +1 -0
  81. matrx_social-0.1.1/tests/fixtures/reddit_subreddit_details.json +1 -0
  82. matrx_social-0.1.1/tests/fixtures/snap_profile.json +183 -0
  83. matrx_social-0.1.1/tests/fixtures/th_post.json +1 -0
  84. matrx_social-0.1.1/tests/fixtures/th_posts.json +1 -0
  85. matrx_social-0.1.1/tests/fixtures/th_profile.json +1 -0
  86. matrx_social-0.1.1/tests/fixtures/th_search.json +1 -0
  87. matrx_social-0.1.1/tests/fixtures/tt_ads.json +1 -0
  88. matrx_social-0.1.1/tests/fixtures/tt_comments.json +1 -0
  89. matrx_social-0.1.1/tests/fixtures/tt_profile.json +1 -0
  90. matrx_social-0.1.1/tests/fixtures/tt_profile_empty.json +1 -0
  91. matrx_social-0.1.1/tests/fixtures/tt_transcript.json +1 -0
  92. matrx_social-0.1.1/tests/fixtures/tt_video.json +1 -0
  93. matrx_social-0.1.1/tests/fixtures/tt_videos.json +1 -0
  94. matrx_social-0.1.1/tests/fixtures/x_profile.json +1 -0
  95. matrx_social-0.1.1/tests/fixtures/x_tweet.json +1 -0
  96. matrx_social-0.1.1/tests/fixtures/x_tweets.json +1 -0
  97. matrx_social-0.1.1/tests/fixtures/yt_channel.json +1 -0
  98. matrx_social-0.1.1/tests/fixtures/yt_chvideos.json +1 -0
  99. matrx_social-0.1.1/tests/fixtures/yt_comments.json +1 -0
  100. matrx_social-0.1.1/tests/fixtures/yt_transcript.json +1 -0
  101. matrx_social-0.1.1/tests/fixtures/yt_transcript_linebreaks.json +48 -0
  102. matrx_social-0.1.1/tests/fixtures/yt_transcript_rolling.json +1 -0
  103. matrx_social-0.1.1/tests/fixtures/yt_video.json +1 -0
  104. matrx_social-0.1.1/tests/test_brightdata.py +555 -0
  105. matrx_social-0.1.1/tests/test_carousel_media.py +72 -0
  106. matrx_social-0.1.1/tests/test_credit_balance.py +30 -0
  107. matrx_social-0.1.1/tests/test_data_defects.py +69 -0
  108. matrx_social-0.1.1/tests/test_errors_never_name_the_vendor.py +55 -0
  109. matrx_social-0.1.1/tests/test_fediverse.py +88 -0
  110. matrx_social-0.1.1/tests/test_grok_x.py +81 -0
  111. matrx_social-0.1.1/tests/test_http_content_refusals.py +40 -0
  112. matrx_social-0.1.1/tests/test_live.py +97 -0
  113. matrx_social-0.1.1/tests/test_liveness.py +116 -0
  114. matrx_social-0.1.1/tests/test_mastodon_public.py +74 -0
  115. matrx_social-0.1.1/tests/test_normalize.py +209 -0
  116. matrx_social-0.1.1/tests/test_pinterest_reddit.py +162 -0
  117. matrx_social-0.1.1/tests/test_post_links.py +31 -0
  118. matrx_social-0.1.1/tests/test_profile_field_map.py +122 -0
  119. matrx_social-0.1.1/tests/test_profile_handle.py +136 -0
  120. matrx_social-0.1.1/tests/test_profile_sites.py +31 -0
  121. matrx_social-0.1.1/tests/test_refused_lookups.py +27 -0
  122. matrx_social-0.1.1/tests/test_review_fixes.py +85 -0
  123. matrx_social-0.1.1/tests/test_router_transport.py +105 -0
  124. matrx_social-0.1.1/tests/test_thumbnails_and_refusals.py +106 -0
@@ -0,0 +1,351 @@
1
+ *.pyc
2
+ secrets/
3
+ ignore/
4
+ temp/
5
+ logs/
6
+ # The broad `logs/` rule above is for RUNTIME log output, but it also matched
7
+ # the dashboard's SOURCE directory and silently swallowed an entire feature's
8
+ # files (only the pre-existing index.tsx stayed tracked), breaking the prod
9
+ # Docker build with "Could not resolve ./structured-tab". Re-include the source.
10
+ !apps/dashboard/src/features/logs/
11
+ !apps/dashboard/src/features/logs/**
12
+ todo
13
+ text_notes/
14
+ aidream/secrets/2.env
15
+ automation_matrix/matrix_processing/temp/*
16
+ cd
17
+ # Byte-compiled / optimized / DLL files
18
+ __pycache__/
19
+ *.py[cod]
20
+ *$py.class
21
+
22
+ # C extensions
23
+ *.so
24
+ .venv/
25
+
26
+ # Distribution / packaging
27
+ .Python
28
+ build/
29
+ develop-eggs/
30
+ dist/
31
+ downloads/
32
+ eggs/
33
+ .eggs/
34
+ lib/
35
+ lib64/
36
+ # The blanket lib/ rule above is from the standard Python .gitignore template
37
+ # and was silently swallowing TS source under the SPA `src/lib/` folders.
38
+ # Re-allow them explicitly so frontend builds don't ship without their lib layer.
39
+ !apps/dashboard/src/lib/
40
+ !apps/dashboard/src/lib/**
41
+ !apps/dashboard/src/features/crawler/lib/
42
+ !apps/dashboard/src/features/crawler/lib/**
43
+ !apps/workflow-studio/src/lib/
44
+ !apps/workflow-studio/src/lib/**
45
+ # Shared package source: a `lib/` folder under apps/shared/<pkg>/src is TS source, never a Python build dir.
46
+ # 2026-10-16: data's src/access/lib/governedActionError.ts stayed untracked, so the publish gate (a clean
47
+ # checkout) failed TS2307 and blocked data and every dependent.
48
+ !apps/shared/*/src/**/lib/
49
+ !apps/shared/*/src/**/lib/**
50
+ parts/
51
+ sdist/
52
+ var/
53
+ wheels/
54
+ share/python-wheels/
55
+ *.egg-info/
56
+ .installed.cfg
57
+ *.egg
58
+ MANIFEST
59
+
60
+ # PyInstaller
61
+ # Usually these files are written by a python script from a template
62
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
63
+ *.manifest
64
+ *.spec
65
+
66
+ # Installer logs
67
+ pip-log.txt
68
+ pip-delete-this-directory.txt
69
+
70
+ # Unit test / coverage reports
71
+ ai/tests/clean_response.json
72
+ ai/tests/cx_storage_response.json
73
+ ai/tests/execution_test.py
74
+ ai/tests/final_response.json
75
+ htmlcov/
76
+ .tox/
77
+ .nox/
78
+ .coverage
79
+ .coverage.*
80
+ .cache
81
+ nosetests.xml
82
+ coverage.xml
83
+ *.cover
84
+ *.py,cover
85
+ .hypothesis/
86
+ .pytest_cache/
87
+ cover/
88
+
89
+ # Translations
90
+ *.mo
91
+ *.pot
92
+
93
+ # Django stuff:
94
+ *.log
95
+ local_settings.py
96
+ db.sqlite3
97
+ db.sqlite3-journal
98
+
99
+ # Flask stuff:
100
+ instance/
101
+ .webassets-cache
102
+
103
+ # Scrapy stuff:
104
+ .scrapy
105
+
106
+ # Sphinx documentation
107
+ docs/_build/
108
+
109
+ # PyBuilder
110
+ .pybuilder/
111
+ target/
112
+
113
+ # Jupyter Notebook
114
+ .ipynb_checkpoints
115
+
116
+ # IPython
117
+ profile_default/
118
+ ipython_config.py
119
+
120
+ # pyenv
121
+ # For a library or package, you might want to ignore these files since the code is
122
+ # intended to run in multiple environments; otherwise, check them in:
123
+ # .python-version
124
+
125
+ # pipenv
126
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
127
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
128
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
129
+ # install all needed dependencies.
130
+ #Pipfile.lock
131
+
132
+ # poetry
133
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
134
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
135
+ # commonly ignored for libraries.
136
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
137
+
138
+ # pdm
139
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
140
+ #pdm.lock
141
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
142
+ # in version control.
143
+ # https://pdm.fming.dev/#use-with-ide
144
+ .pdm.toml
145
+
146
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
147
+ __pypackages__/
148
+
149
+ # Celery stuff
150
+ celerybeat-schedule
151
+ celerybeat.pid
152
+
153
+ # SageMath parsed files
154
+ *.sage.py
155
+
156
+ # Environments
157
+ .env
158
+ .env_remote
159
+ .venv
160
+ env/
161
+ venv/
162
+ ENV/
163
+ env.bak/
164
+ venv.bak/
165
+ .env.armanonly
166
+
167
+ # Spyder project settings
168
+ .spyderproject
169
+ .spyproject
170
+
171
+ # Rope project settings
172
+ .ropeproject
173
+
174
+ # mkdocs documentation
175
+ /site
176
+
177
+ # mypy
178
+ .mypy_cache/
179
+ .dmypy.json
180
+ dmypy.json
181
+
182
+ # Pyre type checker
183
+ .pyre/
184
+
185
+ # random armani files
186
+ /armani_dev/secrets/
187
+ /armani/
188
+ /_armani/
189
+
190
+
191
+
192
+ # pytype static type analyzer
193
+ .pytype/
194
+
195
+ # Cython debug symbols
196
+ cython_debug/
197
+
198
+ .idea/
199
+ .vscode/
200
+ /node_modules/
201
+
202
+ # Frontend pnpm workspace (apps/) — node_modules at the workspace root and any
203
+ # member, plus Vite caches and build output. The unified lockfile (apps/pnpm-lock.yaml)
204
+ # IS committed; everything below is regenerated.
205
+ #
206
+ # 🚨 BOTH SPELLINGS. `node_modules/` with the slash matches a DIRECTORY only, so
207
+ # a SYMLINK named `node_modules` slipped straight past it: five of them — each
208
+ # an absolute path into one machine's checkout — were committed on main and
209
+ # broke `pnpm install --frozen-lockfile` on every CI runner, where the symlink
210
+ # is dangling and pnpm's `mkdir` of it answers ENOENT. Every package release was
211
+ # blocked by it. The bare spelling below matches the symlink too.
212
+ node_modules
213
+ node_modules/
214
+ apps/**/.vite/
215
+ apps/**/dist/
216
+ .vite/
217
+
218
+ dump.rdb
219
+
220
+ frontend/
221
+
222
+ # AME Temp Files and directory structure
223
+ # Ignore all files in the temp directory and its subdirectories
224
+ /temp/**/*
225
+ /tmp/**/*
226
+
227
+ # Allow .gitkeep files to retain directory structure
228
+ !/temp/**/.gitkeep
229
+ !/tmp/**/.gitkeep
230
+
231
+ # Armani
232
+ .history*
233
+ .history/
234
+ /local_data/
235
+ local_reports_data/
236
+ webscraper/quick_scrapes/temp/
237
+ automation_matrix/ai_apis/fireworks/_dev/*
238
+ automation_matrix/ai_apis/fireworks/_dev/fireworks_sample.py
239
+ *.pdf
240
+ *.flac
241
+ *.mp3
242
+ *.wav
243
+ miniconda.sh
244
+ /database/python_sql/temp_data/
245
+ .history*
246
+ .history/
247
+ .history/
248
+
249
+ _dev/
250
+ /_dev/
251
+ requirements_filtered.txt
252
+
253
+ # matrx-dev-tools backups
254
+ .env-backups/
255
+ # Matrx Ship config (contains API key)
256
+ .matrx-ship.json
257
+
258
+ # Matrx config (contains API keys)
259
+ .matrx.json
260
+ .matrx-tools.conf
261
+
262
+ # Claude Code local worktrees and per-user settings
263
+ .claude/worktrees/
264
+ .worktrees/
265
+ .claude/settings.local.json
266
+
267
+ # Append-only snapshots from matrx_utils.update_history (unbounded; do not commit)
268
+ common/utils/data_in_code/data_history.json
269
+ packages/matrx-utils/matrx_utils/data_in_code/data_history.json
270
+
271
+ # Tool-dispatch debug logs — one file per server start, never committed
272
+ .matrx-debug/
273
+ # Per-definition BEFORE snapshots written by a live migration script. They are
274
+ # the recovery record for one machine's run, not repo content — the durable
275
+ # audit trail is the `metadata.migrations` stamp on the row itself.
276
+ .matrx-migrations/
277
+
278
+ # Trial Bench records written on THIS machine — the trial's JSON record, its §8
279
+ # report and the index that finds them (aidream/services/masterworks/bench/index.py,
280
+ # and the runs a product-started trial writes beside it). They are never repo
281
+ # content: a record carries every arm's full text INCLUDING the expert's ground
282
+ # truth, which is material deliberately withheld from the Rulebook, and a trial
283
+ # that matters is published deliberately under common-docs/projects/<its trial>/.
284
+ # Untracked-but-not-ignored meant one `git add -A` away from committing an
285
+ # expert's withheld work.
286
+ .matrx/bench/
287
+
288
+ # macOS Finder metadata
289
+ .DS_Store
290
+ **/.DS_Store
291
+
292
+ # Environment files
293
+ .env
294
+ .env.*
295
+ *.env
296
+ *.env.*
297
+
298
+ # Keep safe templates trackable
299
+ !.env.example
300
+ !.env.sample
301
+ !.env.template
302
+
303
+ # Never commit local OAuth client/token artifacts
304
+ credentials.json
305
+ token*.pickle
306
+ token*.json
307
+ tests_trials/rag_tests/kg_export_output/
308
+
309
+ # Pooler-contamination watch output (machine-local evidence, not source)
310
+ db/evidence/
311
+
312
+ # Pooler contamination evidence — machine-local forensic capture, not source
313
+ db/evidence/
314
+ db/mirror/.env.mirror
315
+ supabase/.temp/
316
+ **/supabase/.temp/
317
+ # Packed npm artifacts. Kept locally for a one-time bootstrap publish; never
318
+ # committed (they are large binaries regenerable with `pnpm pack`).
319
+ apps/shared/*/*.tgz
320
+
321
+ # Guard forcing-function scratch packages. These tests plant real files inside
322
+ # the real tree the guard scans (a temp dir would prove nothing) and delete
323
+ # them in teardown — ignoring them keeps the auto-commit tooling from
324
+ # capturing one mid-run.
325
+ _kind_boundary_scratch*/
326
+ _kind_marker_law_scratch*/
327
+
328
+ # packed npm tarballs are build output, never source (doctrine: typescript-package-standard)
329
+ *.tgz
330
+
331
+ # Peers' kit scratch witnesses. A `.kit-<name>-witness.XXXXXX/` directory is a
332
+ # full COPY of kit/src planted inside the packages tree — three of them held a
333
+ # duplicate `format.ts` on 2026-09-12 — and it sits in the `check:package-twins`
334
+ # scan ROOT. `git ls-files` only reaches TRACKED files, so the guard never saw
335
+ # them; the risk was a broad `git add` committing three formatter twins in one
336
+ # stroke, which is the exact class that guard exists to stop. Ignored, never
337
+ # deleted: they are another agent's evidence.
338
+ apps/shared/.kit-*-witness.*/
339
+
340
+ # Agent worktrees. Extra checkouts of this same repo, never product source.
341
+ # They live at .wt/<slug> (disk-groom). Committing the folder would add a
342
+ # second copy of the entire tree and a symlink into common-docs.
343
+ .wt/
344
+ .matrx/fixer-dispatch.json
345
+ .matrx/fixer-ledger.json
346
+
347
+ # Release streak ledger (scripts/release_repeats.py): per-machine state, never committed
348
+ .matrx/release-repeats.json
349
+ .matrx/release-repeats.json.tmp
350
+ .matrx/fixer-ledger.json
351
+ /.config-manifest.json
@@ -0,0 +1,53 @@
1
+ # matrx-social
2
+
3
+ Provider-agnostic social data for Social Intelligence (`common-docs/systems/marketing/social/`). Depends only on pydantic + httpx; owns no ledger, no DB, no mock mode.
4
+
5
+ - **Contracts** (`contracts.py`): `Platform`, `SocialProfile`, `SocialPost`, `MetricSet`, `Comment`, `Transcript`, `Ad`, `Page[T]`, `ProviderResult[T]`. A metric the provider does not give is `None`, never `0`. Every result carries `provider`, `fallback_reason`, `cost_credits`, and the `raw` payload.
6
+ - **Interface** (`interface.py`): `SocialProvider` — `get_post`, `get_profile`, `list_posts`, `get_comments`, `get_transcript`, `search`, `trending`, `search_ads`, `capabilities`.
7
+ - **ScrapeCreators** (`providers/scrapecreators/`): primary. Only operations whose real payload was captured and normalized are in `capabilities()`; anything else raises `UnsupportedError`. Snapchat and comments/search on several platforms are NOT wired yet (see `common-docs/systems/marketing/social/STATE.md` for the measured picture). A lookup of a missing account returns HTTP 200 + `account_deactivated` and is billed 1 credit; the adapter raises `NotFoundError(credits_charged=1)`. A lookup the platform refused answers HTTP 200 + `success: true` with `error`/`errorStatus`/`message` (e.g. Instagram `not_found` 404 "Profile is private"); `_get` raises `NotFoundError` for it (other `errorStatus` values: `SocialProviderError`, retryable only on 5xx) — never an empty normalized profile.
8
+ - **Pinterest** (wired 2026-10-09): no profile endpoint exists. `get_profile` reads `/v1/pinterest/user/boards` (1 credit; the owner block gives handle, id, followers; `extras.boards` holds the boards on that page; `post_count` stays `None` unless the boards listing is complete). A board URL resolves to its owner. `list_posts` = pins of the account's boards, ONE board per page (cursor = board index, 1 credit/page, boards with 0 pins skipped; `effective_params` names the board). `get_post` = `/v1/pinterest/pin`; keyword `search` = `/v1/pinterest/search` (items carry NO engagement numbers: all metrics `None`; non-pin cards skipped and counted in `effective_params.skipped_non_pins`). No comments, no transcript. **Outlier metric = saves** (`aggregatedStats.saves`, the platform's own measure, larger than `repinCount` which rides in `extras.repins`); likes = reaction total, comments = `commentCount`, shares = `shareCount`, views always `None`. Pin video shape was not captured (no video pin in any probe): a video pin without a parsable mp4 carries `extras.video_unparsed`.
9
+ - **Reddit** (wired 2026-10-09): no user endpoint, so the trackable unit is a SUBREDDIT (`handle` = subreddit name, `display_name` = `r/name`, id = `t5_…`). `get_profile` = `/v1/reddit/subreddit/details` (`followers` = `subscribers`, `None` when the vendor sends null, as it did for r/Entrepreneur); `list_posts` = `/v1/reddit/subreddit` (sort=new, `after` cursor); `get_post` = `/v1/reddit/post`; `get_comments` = `/v1/reddit/post/comments` (cursor from `more.cursor`); keyword `search` = `/v1/reddit/search` (posts). **Outlier metric = score (net upvotes) stored as `likes`**; comments = `num_comments`; views `None`. No transcript wired (the vendor has one for video posts).
10
+ - **Threads** now also has keyword `search` (`/v1/threads/search`, one page, no cursor).
11
+ - **Outlier metric per platform** lives in `aidream/services/social/stats.py::PRIMARY_METRIC` (views; Pinterest saves; Reddit and Threads likes) and is mirrored in matrx-frontend `features/marketing/social/outlier.ts`. The stored `post_stat.baseline_views` column holds the baseline of that platform's primary metric; `velocity_24h` is only computed for views platforms.
12
+ - **Transcripts** (`normalize.parse_transcript`): captions are cleaned (`clean_caption_text`: zero-width characters stripped, whitespace collapsed) and rolling/overlapping segments de-duplicated (`dedupe_caption_segments`); text is rebuilt from the segments, never taken from the vendor's `transcript_only_text` (YouTube repeats every line there: a 20-minute video counted 54,558 words, really ~11k). Test on the real payload `tests/fixtures/yt_transcript_rolling.json`.
13
+ - **HTTP content refusals**: a structured HTTP 403 verdict naming a restricted/private/missing post or profile is classified by the same `errors.refusal_kind` as a 200 refusal. Unknown 403s and credential/IP refusals remain `AuthError`; zero stated credits stay zero. Regression fixture: `tests/fixtures/ig_post_age_restricted.json`.
14
+ - **Mastodon** (`providers/mastodon_public/`, MKT-109): free, keyless, always configured. An account is read from ITS OWN server (`/api/v1/accounts/lookup`, `/accounts/<id>/statuses`); handles are `user@host` (a bare `user` is `mastodon.social`); profile fields ride in `extras["fields"]`, the first (verified first) link in `extras["external_url"]`. Cost is always 0. A host that does not answer the Mastodon API is `NotFoundError(reason="missing")`.
15
+ - **Failure types**: a vendor payload missing a key the adapter needs raises `UnexpectedShapeError(endpoint, missing)` (the caller records it and may fall back). `AuthError`/`OutOfCreditsError` are the account-level failures a caller treats as critical (`ProviderAccountError` is what a caller raises when every provider fails that way). Defaults the adapter applied (trending region, Meta ads country) are reported on `ProviderResult.effective_params`.
16
+ - **Routing is not in this package.** The provider router (chain order, fallback, chat-vs-screen `read_context`, definitive-refusal stop, credit aggregation) was deleted in 0.1.1; routing now lives as data in the platform tool catalog (`aidream/services/tool_catalog/seeds/social.py`, tested by `aidream/services/tool_catalog/tests/test_social_family_is_the_old_router_as_data.py`). This package keeps only the single-provider adapters, the contracts (`ReadContext` and `DEEPER_OPTION` are in `contracts.py`) and the error types. `list_posts(profile, cursor, limit=N)` carries the TOTAL posts wanted (a hint for count-based providers; Bright Data deep posts read it, ScrapeCreators ignores it). EnsembleData (`ENSEMBLEDATA_TOKEN`) and Apify (`APIFY_API_TOKEN`) are registered in `providers/__init__.py` but have no key in any `.env` and no adapter yet.
17
+ - **Cost**: `transport.py` calls registered observers with `SocialBillableCall(credits_charged)`. aidream wires `aidream/services/runtime/external_api_observers.py::_on_social_call` (units = credits, priced by knob `platform.external_api_prices.scrapecreators_credit_usd`).
18
+ - **Key**: env `SCRAPECREATORS_API_KEY` (REQUIRED_ENV, credential definition `scrapecreators_api_key`).
19
+
20
+ Tests: `pytest packages/matrx-social -m "not live"` — unit tests normalize REAL captured payloads (`tests/fixtures/`, captured 2026-10-09; Pinterest/Reddit/Threads-search fixtures `pin_*`, `reddit_*`, `th_search`); `-m live` makes real paid calls (~12 credits, skipped without the key; a plain `pytest` WITH the key set runs them and spends). Battery: `scripts/day_one_battery.py OUT.json` (~60 credits).
21
+
22
+ ## grok_x — X data through Grok (second X route, beside the direct X API)
23
+
24
+ `providers/grok_x/client.py`; env `XAI_API_KEY` (already in REQUIRED_ENV); optional `GROK_X_MODEL` (default `grok-4.20-0309-non-reasoning`, the cheapest model measured to work; never a premium/reasoning model). Calls the xAI Responses API (`POST /v1/responses`) with the server-side `x_search` tool, `max_turns=2`, and a strict JSON schema — one request, no agent loop.
25
+ - **Supports on X only:** `get_post` (status URL), `get_profile`, `list_posts` (by handle), keyword + hashtag `search`. Everything else raises `UnsupportedError`. No cursor: one page of `max_posts` (default 10; also the spend cap).
26
+ - **Registered** in `providers/__init__.py` as `grok_x` after ScrapeCreators and Bright Data. Order between providers is the catalog's, not this package's.
27
+ - **Honesty:** xAI does not hand back the raw X data; numbers reach us transcribed by the model. Every item carries `extras["extraction"]="grok_transcribed"`; not-shown fields are `None` (profile `following`/`post_count` came back null on NASA); posts with a non-numeric id are dropped (`effective_params.dropped_invalid`). Metrics differ slightly between two reads seconds apart (live counts) — treat as approximate and prefer ScrapeCreators for get_post/profile/list when both exist.
28
+ - **Cost:** xAI reports exact USD per request (`usage.cost_in_usd_ticks`, 1 tick = 1e-10 USD; tokens + X Search at $5/1k posts and $10/1k profiles fetched, per docs.x.ai/developers/tools/x-search and /pricing). Surfaced as `ProviderResult.cost_usd` and `SocialBillableCall.reported_usd`; `_on_social_call` records ONE `external_api` ledger row priced by the vendor figure (no knob). It bypasses matrx-ai, so no token row exists: counted exactly once. SOC-POINTS reads that row (`provider=grok_x`). Measured: profile ~$0.033, 3 posts ~$0.02-0.04, search ~$0.04.
29
+ - **Ruling (AI-mandate law):** this is a vendor data-retrieval call with a fixed extraction prompt and no persona, tools choice, or loop — an API adapter like ScrapeCreators, not an agent, so it does not go through a mandate/agent factory. If a future use needs Grok to reason over results, that is a separate mandate-backed agent fed by this adapter's output.
30
+ - Rate limits: not published for x_search ("per-minute limits apply"); retried on 429/5xx with Retry-After. Terms of X-content use via xAI not reviewed — open.
31
+ - Tests: `tests/test_grok_x.py` (real captured payload `fixtures/grok_x_responses_nasa_latest.json`); live `test_grok_x_live_search_tiny_spend` (~$0.02).
32
+
33
+ ## brightdata — second provider (fallback behind ScrapeCreators)
34
+
35
+ `providers/brightdata/{client,normalize}.py`; env `BRIGHT_DATA_API_KEY` (read like `SCRAPECREATORS_API_KEY`; already in REQUIRED_ENV). Bright Data Web Scraper API datasets, billed **per returned record**.
36
+ - **Wire:** `POST /datasets/v3/scrape?dataset_id=…&format=json&include_errors=true`, Bearer key, body `[{"url": …}]`. Sync 200 (records) within ~60 s, else 202 `{snapshot_id}` → poll `/datasets/v3/progress/{id}` (bounded: `poll_timeout_s`=180, announced in the error; a timed-out job is recorded at its cap, never as free) → `/datasets/v3/snapshot/{id}`. A read timeout on the POST is never retried (the job may already bill). Error records: `dead_page` → `NotFoundError(reason="missing")`; `blocked`/`crawl_error` → retryable `SocialProviderError`.
37
+ - **🚨 Cap:** every request carries `limit_per_input` AND `limit_multiple_results`; `BrightDataTransport.scrape` raises `UncappedRequestError` before sending without a positive cap (an uncapped IG comments read returned 522 records). **Timeout:** a snapshot poll timeout POSTs `/datasets/v3/snapshot/{id}/cancel` (never retried), reports "job cancelled" or "cancel FAILED" in the error and the call detail, and still books the cap as spend. **No double jobs:** the trigger POST is retried only on connection errors before send and on 429 with `Retry-After`; a 5xx/read timeout is never re-POSTed (it may have started a billed job). Live 2026-10-11: both params at 2 → exactly 2 IG comment records. Which one alone is honoured was not isolated (testing the wrong one alone could bill hundreds of records).
38
+ - **Cost:** `SocialBillableCall(provider="brightdata", credits_charged=records returned)`, path `/brightdata/<dataset_id>/<op>` with the operation word (`profile`/`post`/`posts`/`comments`) so `_on_social_call` classifies it; priced by knob `platform.external_api_prices.brightdata_record_usd` (0.0015). Error records count as returned records (conservative; unverified whether Bright Data bills them).
39
+ - **Why its own transport, not matrx-scraper's Bright Data client:** a social read must be costed through the social observer (`_on_social_call`): subject attribution (`cost_subject`) and the shared-refresh cost split across tracking organizations. matrx-scraper's client feeds `_on_brightdata_call` (site data, no subject, no split). Same vendor, two cost meanings.
40
+ - **Capabilities (only what a real record was normalized + tested for; `tests/test_brightdata.py`, fixtures `tests/fixtures/brightdata/`):** Instagram profile/post/list/comments; TikTok profile/post/list/comments (comments embedded in the post record); YouTube channel/video; LinkedIn person/post/list/comments (embedded `top_visible_comments`); Facebook page profile; X profile/post/list; Threads profile; Pinterest profile; Reddit list (subreddit discover, cap = `posts_page_size` 25). Everything else (transcripts, search, trending, ads, cursors) raises `UnsupportedError`. One page only: a cursor is refused, never re-billed.
41
+ - **Deep posts (X, LinkedIn):** `list_posts` uses TRIGGER mode, `POST /datasets/v3/trigger?dataset_id=…&type=discover_new&discover_by=profile_url` (the sync `/scrape` returned 0 records for a discover), always snapshot-polled with its own bound `BrightDataTransport(discover_timeout_s=600)` (cancel-on-timeout, cap booked, fail-closed cap unchanged). X input carries `num_of_posts` = the requested count; the LinkedIn posts dataset REJECTS `num_of_posts` (400), so only `limit_per_input` bounds it. Count = `limit` hint, else `BrightDataProvider(deep_posts_default=50)`, ceiling `deep_posts_max=200`; one page, no cursor. Measured: 50 X posts in 27 s, 10 LinkedIn posts with like/comment counts in 34 s. Other platforms: the next sentence.
42
+ - **list_posts** (other platforms) reads the posts embedded in the profile record; a profile record fetched in the last 10 minutes is reused (0 records). Instagram and LinkedIn embedded posts carry NO metrics (all `None`).
43
+ - **Honesty rules:** X profiles answer `followers/following/posts_count = 0` beside real posts → all `None`, `extras.counts_withheld`. X retweets in a profile carry `likes=0, replies=0` → `None`. Ids follow ScrapeCreators (IG `pk`, Reddit id without `t3_`, LinkedIn no `platform_user_id`) so both providers land on the same rows. Every item has `extras.source_dataset`; `raw` = the record.
44
+ - **Live proof 2026-10-11:** local server with a dead ScrapeCreators key → `/social/ingest/profile` TikTok @nasa fell back (`fallback_reason` "scrapecreators error: … HTTP 401"), stored 2,100,000 followers / 56 posts / 10.9M likes + 17 posts, 1 record = $0.0015 on one external_api row.
45
+
46
+ ## Data defects fixed 2026-10-11 (head-to-head)
47
+ - ScrapeCreators Instagram `post_count`: the payload's `edge_owner_to_timeline_media.count` is the page size (12) while `has_next_page` is true; the real total is not in the payload, so it is `None` unless the count exceeds the page or the page is the whole account.
48
+ - ScrapeCreators X reposts (`RT @…` or `retweeted_status_result`): likes, comments, saves are `None` (the vendor says 0), `extras.is_repost`.
49
+ - Bright Data X profile counts 0 beside posts: `None` (`counts_withheld`), tested.
50
+ - ScrapeCreators Pinterest "no public boards": a retryable `SocialProviderError`, not `NotFoundError`; the vendor answered empty for an account Bright Data reads, so it is never "the account does not exist" and never an empty profile.
51
+
52
+ ## Temporary: remove or disable before launch — test replay seam (REMOVE-BEFORE-LAUNCH)
53
+ `transport.py` `register_social_replay(lookup, store)` / `ReplayHit`: a host-registered pair asked before every ScrapeCreators GET and Bright Data scrape (a hit is returned instead of the request: no guard, no observer, nothing billed) and handed each real paid answer afterwards. Unregistered (every package test, every non-aidream host) it is a no-op; a seam error is logged and treated as a miss. aidream's gate (admins/fixtures, switch, expensive list) is `aidream/services/runtime/admin_replay_cache.py`. Delete the seam with the cache before go-live (`rg REMOVE-BEFORE-LAUNCH`).
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.5
2
+ Name: matrx-social
3
+ Version: 0.1.1
4
+ Summary: Provider-agnostic social data: contracts, provider interface, provider adapters.
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: httpx>=0.28
7
+ Requires-Dist: pydantic>=2.12
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest-asyncio>=0.25; extra == 'dev'
10
+ Requires-Dist: pytest>=8.3; extra == 'dev'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # matrx-social
14
+
15
+ Provider-agnostic social data for Social Intelligence (`common-docs/systems/marketing/social/`). Depends only on pydantic + httpx; owns no ledger, no DB, no mock mode.
16
+
17
+ - **Contracts** (`contracts.py`): `Platform`, `SocialProfile`, `SocialPost`, `MetricSet`, `Comment`, `Transcript`, `Ad`, `Page[T]`, `ProviderResult[T]`. A metric the provider does not give is `None`, never `0`. Every result carries `provider`, `fallback_reason`, `cost_credits`, and the `raw` payload.
18
+ - **Interface** (`interface.py`): `SocialProvider` — `get_post`, `get_profile`, `list_posts`, `get_comments`, `get_transcript`, `search`, `trending`, `search_ads`, `capabilities`.
19
+ - **ScrapeCreators** (`providers/scrapecreators/`): primary. Only operations whose real payload was captured and normalized are in `capabilities()`; anything else raises `UnsupportedError`. Snapchat and comments/search on several platforms are NOT wired yet (see `common-docs/systems/marketing/social/STATE.md` for the measured picture). A lookup of a missing account returns HTTP 200 + `account_deactivated` and is billed 1 credit; the adapter raises `NotFoundError(credits_charged=1)`. A lookup the platform refused answers HTTP 200 + `success: true` with `error`/`errorStatus`/`message` (e.g. Instagram `not_found` 404 "Profile is private"); `_get` raises `NotFoundError` for it (other `errorStatus` values: `SocialProviderError`, retryable only on 5xx) — never an empty normalized profile.
20
+ - **Pinterest** (wired 2026-10-09): no profile endpoint exists. `get_profile` reads `/v1/pinterest/user/boards` (1 credit; the owner block gives handle, id, followers; `extras.boards` holds the boards on that page; `post_count` stays `None` unless the boards listing is complete). A board URL resolves to its owner. `list_posts` = pins of the account's boards, ONE board per page (cursor = board index, 1 credit/page, boards with 0 pins skipped; `effective_params` names the board). `get_post` = `/v1/pinterest/pin`; keyword `search` = `/v1/pinterest/search` (items carry NO engagement numbers: all metrics `None`; non-pin cards skipped and counted in `effective_params.skipped_non_pins`). No comments, no transcript. **Outlier metric = saves** (`aggregatedStats.saves`, the platform's own measure, larger than `repinCount` which rides in `extras.repins`); likes = reaction total, comments = `commentCount`, shares = `shareCount`, views always `None`. Pin video shape was not captured (no video pin in any probe): a video pin without a parsable mp4 carries `extras.video_unparsed`.
21
+ - **Reddit** (wired 2026-10-09): no user endpoint, so the trackable unit is a SUBREDDIT (`handle` = subreddit name, `display_name` = `r/name`, id = `t5_…`). `get_profile` = `/v1/reddit/subreddit/details` (`followers` = `subscribers`, `None` when the vendor sends null, as it did for r/Entrepreneur); `list_posts` = `/v1/reddit/subreddit` (sort=new, `after` cursor); `get_post` = `/v1/reddit/post`; `get_comments` = `/v1/reddit/post/comments` (cursor from `more.cursor`); keyword `search` = `/v1/reddit/search` (posts). **Outlier metric = score (net upvotes) stored as `likes`**; comments = `num_comments`; views `None`. No transcript wired (the vendor has one for video posts).
22
+ - **Threads** now also has keyword `search` (`/v1/threads/search`, one page, no cursor).
23
+ - **Outlier metric per platform** lives in `aidream/services/social/stats.py::PRIMARY_METRIC` (views; Pinterest saves; Reddit and Threads likes) and is mirrored in matrx-frontend `features/marketing/social/outlier.ts`. The stored `post_stat.baseline_views` column holds the baseline of that platform's primary metric; `velocity_24h` is only computed for views platforms.
24
+ - **Transcripts** (`normalize.parse_transcript`): captions are cleaned (`clean_caption_text`: zero-width characters stripped, whitespace collapsed) and rolling/overlapping segments de-duplicated (`dedupe_caption_segments`); text is rebuilt from the segments, never taken from the vendor's `transcript_only_text` (YouTube repeats every line there: a 20-minute video counted 54,558 words, really ~11k). Test on the real payload `tests/fixtures/yt_transcript_rolling.json`.
25
+ - **HTTP content refusals**: a structured HTTP 403 verdict naming a restricted/private/missing post or profile is classified by the same `errors.refusal_kind` as a 200 refusal. Unknown 403s and credential/IP refusals remain `AuthError`; zero stated credits stay zero. Regression fixture: `tests/fixtures/ig_post_age_restricted.json`.
26
+ - **Mastodon** (`providers/mastodon_public/`, MKT-109): free, keyless, always configured. An account is read from ITS OWN server (`/api/v1/accounts/lookup`, `/accounts/<id>/statuses`); handles are `user@host` (a bare `user` is `mastodon.social`); profile fields ride in `extras["fields"]`, the first (verified first) link in `extras["external_url"]`. Cost is always 0. A host that does not answer the Mastodon API is `NotFoundError(reason="missing")`.
27
+ - **Failure types**: a vendor payload missing a key the adapter needs raises `UnexpectedShapeError(endpoint, missing)` (the caller records it and may fall back). `AuthError`/`OutOfCreditsError` are the account-level failures a caller treats as critical (`ProviderAccountError` is what a caller raises when every provider fails that way). Defaults the adapter applied (trending region, Meta ads country) are reported on `ProviderResult.effective_params`.
28
+ - **Routing is not in this package.** The provider router (chain order, fallback, chat-vs-screen `read_context`, definitive-refusal stop, credit aggregation) was deleted in 0.1.1; routing now lives as data in the platform tool catalog (`aidream/services/tool_catalog/seeds/social.py`, tested by `aidream/services/tool_catalog/tests/test_social_family_is_the_old_router_as_data.py`). This package keeps only the single-provider adapters, the contracts (`ReadContext` and `DEEPER_OPTION` are in `contracts.py`) and the error types. `list_posts(profile, cursor, limit=N)` carries the TOTAL posts wanted (a hint for count-based providers; Bright Data deep posts read it, ScrapeCreators ignores it). EnsembleData (`ENSEMBLEDATA_TOKEN`) and Apify (`APIFY_API_TOKEN`) are registered in `providers/__init__.py` but have no key in any `.env` and no adapter yet.
29
+ - **Cost**: `transport.py` calls registered observers with `SocialBillableCall(credits_charged)`. aidream wires `aidream/services/runtime/external_api_observers.py::_on_social_call` (units = credits, priced by knob `platform.external_api_prices.scrapecreators_credit_usd`).
30
+ - **Key**: env `SCRAPECREATORS_API_KEY` (REQUIRED_ENV, credential definition `scrapecreators_api_key`).
31
+
32
+ Tests: `pytest packages/matrx-social -m "not live"` — unit tests normalize REAL captured payloads (`tests/fixtures/`, captured 2026-10-09; Pinterest/Reddit/Threads-search fixtures `pin_*`, `reddit_*`, `th_search`); `-m live` makes real paid calls (~12 credits, skipped without the key; a plain `pytest` WITH the key set runs them and spends). Battery: `scripts/day_one_battery.py OUT.json` (~60 credits).
33
+
34
+ ## grok_x — X data through Grok (second X route, beside the direct X API)
35
+
36
+ `providers/grok_x/client.py`; env `XAI_API_KEY` (already in REQUIRED_ENV); optional `GROK_X_MODEL` (default `grok-4.20-0309-non-reasoning`, the cheapest model measured to work; never a premium/reasoning model). Calls the xAI Responses API (`POST /v1/responses`) with the server-side `x_search` tool, `max_turns=2`, and a strict JSON schema — one request, no agent loop.
37
+ - **Supports on X only:** `get_post` (status URL), `get_profile`, `list_posts` (by handle), keyword + hashtag `search`. Everything else raises `UnsupportedError`. No cursor: one page of `max_posts` (default 10; also the spend cap).
38
+ - **Registered** in `providers/__init__.py` as `grok_x` after ScrapeCreators and Bright Data. Order between providers is the catalog's, not this package's.
39
+ - **Honesty:** xAI does not hand back the raw X data; numbers reach us transcribed by the model. Every item carries `extras["extraction"]="grok_transcribed"`; not-shown fields are `None` (profile `following`/`post_count` came back null on NASA); posts with a non-numeric id are dropped (`effective_params.dropped_invalid`). Metrics differ slightly between two reads seconds apart (live counts) — treat as approximate and prefer ScrapeCreators for get_post/profile/list when both exist.
40
+ - **Cost:** xAI reports exact USD per request (`usage.cost_in_usd_ticks`, 1 tick = 1e-10 USD; tokens + X Search at $5/1k posts and $10/1k profiles fetched, per docs.x.ai/developers/tools/x-search and /pricing). Surfaced as `ProviderResult.cost_usd` and `SocialBillableCall.reported_usd`; `_on_social_call` records ONE `external_api` ledger row priced by the vendor figure (no knob). It bypasses matrx-ai, so no token row exists: counted exactly once. SOC-POINTS reads that row (`provider=grok_x`). Measured: profile ~$0.033, 3 posts ~$0.02-0.04, search ~$0.04.
41
+ - **Ruling (AI-mandate law):** this is a vendor data-retrieval call with a fixed extraction prompt and no persona, tools choice, or loop — an API adapter like ScrapeCreators, not an agent, so it does not go through a mandate/agent factory. If a future use needs Grok to reason over results, that is a separate mandate-backed agent fed by this adapter's output.
42
+ - Rate limits: not published for x_search ("per-minute limits apply"); retried on 429/5xx with Retry-After. Terms of X-content use via xAI not reviewed — open.
43
+ - Tests: `tests/test_grok_x.py` (real captured payload `fixtures/grok_x_responses_nasa_latest.json`); live `test_grok_x_live_search_tiny_spend` (~$0.02).
44
+
45
+ ## brightdata — second provider (fallback behind ScrapeCreators)
46
+
47
+ `providers/brightdata/{client,normalize}.py`; env `BRIGHT_DATA_API_KEY` (read like `SCRAPECREATORS_API_KEY`; already in REQUIRED_ENV). Bright Data Web Scraper API datasets, billed **per returned record**.
48
+ - **Wire:** `POST /datasets/v3/scrape?dataset_id=…&format=json&include_errors=true`, Bearer key, body `[{"url": …}]`. Sync 200 (records) within ~60 s, else 202 `{snapshot_id}` → poll `/datasets/v3/progress/{id}` (bounded: `poll_timeout_s`=180, announced in the error; a timed-out job is recorded at its cap, never as free) → `/datasets/v3/snapshot/{id}`. A read timeout on the POST is never retried (the job may already bill). Error records: `dead_page` → `NotFoundError(reason="missing")`; `blocked`/`crawl_error` → retryable `SocialProviderError`.
49
+ - **🚨 Cap:** every request carries `limit_per_input` AND `limit_multiple_results`; `BrightDataTransport.scrape` raises `UncappedRequestError` before sending without a positive cap (an uncapped IG comments read returned 522 records). **Timeout:** a snapshot poll timeout POSTs `/datasets/v3/snapshot/{id}/cancel` (never retried), reports "job cancelled" or "cancel FAILED" in the error and the call detail, and still books the cap as spend. **No double jobs:** the trigger POST is retried only on connection errors before send and on 429 with `Retry-After`; a 5xx/read timeout is never re-POSTed (it may have started a billed job). Live 2026-10-11: both params at 2 → exactly 2 IG comment records. Which one alone is honoured was not isolated (testing the wrong one alone could bill hundreds of records).
50
+ - **Cost:** `SocialBillableCall(provider="brightdata", credits_charged=records returned)`, path `/brightdata/<dataset_id>/<op>` with the operation word (`profile`/`post`/`posts`/`comments`) so `_on_social_call` classifies it; priced by knob `platform.external_api_prices.brightdata_record_usd` (0.0015). Error records count as returned records (conservative; unverified whether Bright Data bills them).
51
+ - **Why its own transport, not matrx-scraper's Bright Data client:** a social read must be costed through the social observer (`_on_social_call`): subject attribution (`cost_subject`) and the shared-refresh cost split across tracking organizations. matrx-scraper's client feeds `_on_brightdata_call` (site data, no subject, no split). Same vendor, two cost meanings.
52
+ - **Capabilities (only what a real record was normalized + tested for; `tests/test_brightdata.py`, fixtures `tests/fixtures/brightdata/`):** Instagram profile/post/list/comments; TikTok profile/post/list/comments (comments embedded in the post record); YouTube channel/video; LinkedIn person/post/list/comments (embedded `top_visible_comments`); Facebook page profile; X profile/post/list; Threads profile; Pinterest profile; Reddit list (subreddit discover, cap = `posts_page_size` 25). Everything else (transcripts, search, trending, ads, cursors) raises `UnsupportedError`. One page only: a cursor is refused, never re-billed.
53
+ - **Deep posts (X, LinkedIn):** `list_posts` uses TRIGGER mode, `POST /datasets/v3/trigger?dataset_id=…&type=discover_new&discover_by=profile_url` (the sync `/scrape` returned 0 records for a discover), always snapshot-polled with its own bound `BrightDataTransport(discover_timeout_s=600)` (cancel-on-timeout, cap booked, fail-closed cap unchanged). X input carries `num_of_posts` = the requested count; the LinkedIn posts dataset REJECTS `num_of_posts` (400), so only `limit_per_input` bounds it. Count = `limit` hint, else `BrightDataProvider(deep_posts_default=50)`, ceiling `deep_posts_max=200`; one page, no cursor. Measured: 50 X posts in 27 s, 10 LinkedIn posts with like/comment counts in 34 s. Other platforms: the next sentence.
54
+ - **list_posts** (other platforms) reads the posts embedded in the profile record; a profile record fetched in the last 10 minutes is reused (0 records). Instagram and LinkedIn embedded posts carry NO metrics (all `None`).
55
+ - **Honesty rules:** X profiles answer `followers/following/posts_count = 0` beside real posts → all `None`, `extras.counts_withheld`. X retweets in a profile carry `likes=0, replies=0` → `None`. Ids follow ScrapeCreators (IG `pk`, Reddit id without `t3_`, LinkedIn no `platform_user_id`) so both providers land on the same rows. Every item has `extras.source_dataset`; `raw` = the record.
56
+ - **Live proof 2026-10-11:** local server with a dead ScrapeCreators key → `/social/ingest/profile` TikTok @nasa fell back (`fallback_reason` "scrapecreators error: … HTTP 401"), stored 2,100,000 followers / 56 posts / 10.9M likes + 17 posts, 1 record = $0.0015 on one external_api row.
57
+
58
+ ## Data defects fixed 2026-10-11 (head-to-head)
59
+ - ScrapeCreators Instagram `post_count`: the payload's `edge_owner_to_timeline_media.count` is the page size (12) while `has_next_page` is true; the real total is not in the payload, so it is `None` unless the count exceeds the page or the page is the whole account.
60
+ - ScrapeCreators X reposts (`RT @…` or `retweeted_status_result`): likes, comments, saves are `None` (the vendor says 0), `extras.is_repost`.
61
+ - Bright Data X profile counts 0 beside posts: `None` (`counts_withheld`), tested.
62
+ - ScrapeCreators Pinterest "no public boards": a retryable `SocialProviderError`, not `NotFoundError`; the vendor answered empty for an account Bright Data reads, so it is never "the account does not exist" and never an empty profile.
63
+
64
+ ## Temporary: remove or disable before launch — test replay seam (REMOVE-BEFORE-LAUNCH)
65
+ `transport.py` `register_social_replay(lookup, store)` / `ReplayHit`: a host-registered pair asked before every ScrapeCreators GET and Bright Data scrape (a hit is returned instead of the request: no guard, no observer, nothing billed) and handed each real paid answer afterwards. Unregistered (every package test, every non-aidream host) it is a no-op; a seam error is logged and treated as a miss. aidream's gate (admins/fixtures, switch, expensive list) is `aidream/services/runtime/admin_replay_cache.py`. Delete the seam with the cache before go-live (`rg REMOVE-BEFORE-LAUNCH`).
@@ -0,0 +1,64 @@
1
+ """matrx-social: provider-agnostic social data (contracts, interface, adapters)."""
2
+
3
+ from .contracts import (
4
+ DEEPER_OPTION,
5
+ Ad,
6
+ AdLibrary,
7
+ Attempt,
8
+ Capability,
9
+ Comment,
10
+ Media,
11
+ MediaKind,
12
+ MetricSet,
13
+ Page,
14
+ Platform,
15
+ ProviderResult,
16
+ ReadContext,
17
+ SearchType,
18
+ SocialPost,
19
+ SocialProfile,
20
+ Transcript,
21
+ TranscriptSegment,
22
+ )
23
+ from .errors import (
24
+ AllProvidersFailedError,
25
+ AuthError,
26
+ NotConfiguredError,
27
+ NotFoundError,
28
+ OutOfCreditsError,
29
+ ProviderAccountError,
30
+ SocialProviderError,
31
+ UnexpectedShapeError,
32
+ UnsupportedError,
33
+ )
34
+ from .interface import SocialProvider
35
+ from .providers.brightdata import BrightDataProvider, BrightDataTransport, UncappedRequestError
36
+ from .providers.grok_x import GrokXProvider
37
+ from .providers.scrapecreators import ScrapeCreatorsProvider
38
+ from .transport import (
39
+ AdaptiveRateLimiter,
40
+ RecorderMissingError,
41
+ ReplayHit,
42
+ ScrapeCreatorsTransport,
43
+ SocialBillableCall,
44
+ clear_social_call_guards,
45
+ clear_social_call_observers,
46
+ clear_social_replay,
47
+ ensure_recorder,
48
+ register_social_call_flusher,
49
+ register_social_call_guard,
50
+ register_social_call_observer,
51
+ register_social_replay,
52
+ )
53
+ from .urls import (
54
+ account_for_link,
55
+ detect_platform,
56
+ extract_handle,
57
+ is_personal_profile_url,
58
+ is_post_link,
59
+ post_owner,
60
+ profile_handle,
61
+ youtube_channel_ref,
62
+ )
63
+
64
+ __all__ = [n for n in dir() if not n.startswith("_")]