luckiest-co 1.0.47 → 1.0.49

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 (274) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,733 @@
1
+ """Host-fetched X envelope: the ``--x-posts`` lane.
2
+
3
+ The hosting model fetches posts through its own X connector and writes them
4
+ to a ``.json`` file of flat rows. The engine ingests that file as its X
5
+ source for one run: strict at the envelope level (a malformed, stale, or
6
+ off-topic file fails closed with an exit-2 contract error), lenient per row
7
+ (a bad row is dropped and counted), and trusting nothing a row asserts about
8
+ itself beyond a numeric id and a grammar-valid handle.
9
+
10
+ Shape (``schema: "last30days-x-posts/1"``)::
11
+
12
+ {
13
+ "schema": "last30days-x-posts/1",
14
+ "generated_at": "<ISO-8601>",
15
+ "topic": "<the run topic>",
16
+ "window": {"from": "YYYY-MM-DD", "to": "YYYY-MM-DD"},
17
+ "provider": "<free text>",
18
+ "status": "ok" | "partial" | "error",
19
+ "error": "<short category: credits | not-connected | unavailable | window-unsupported>",
20
+ "calls": [{"lane": "topic|from|mention|related", "handles": [...], "posts": [ROW, ...]}]
21
+ }
22
+
23
+ A ROW is a flat object with exactly ``id``, ``author_handle``, ``created_at``,
24
+ ``text``, ``likes``, ``reposts``, ``replies``, ``quotes``. Any other key is
25
+ ignored (and counted once per row as ``extra-fields``); in particular a
26
+ row-supplied ``url`` is never used for the citation, only cross-checked.
27
+
28
+ The envelope is single-serve: the first X subquery takes the topic-lane rows
29
+ and the handle-lane section takes the lane calls; later X subqueries,
30
+ judge-retry, and thin-retry get nothing (``take_topic`` / ``take_lanes``
31
+ return ``None`` once consumed).
32
+
33
+ Security contract: contract errors name the path and the field, never a
34
+ field value; the envelope's raw ``error`` text reaches stderr only under
35
+ ``LAST30DAYS_DEBUG``; every outcome detail is one of the fixed strings below.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import hashlib
41
+ import json
42
+ import os
43
+ import re
44
+ import stat
45
+ import threading
46
+ from dataclasses import dataclass, field
47
+ from datetime import date, datetime, timedelta, timezone
48
+ from pathlib import Path
49
+ from typing import Any
50
+
51
+ from . import env, health, log, schema
52
+ from .relevance import token_overlap_relevance as _compute_relevance
53
+ from .query import leading_mentions
54
+ from .x_api import _clean_handle, _decode_snowflake, _looks_generated, is_own_post
55
+
56
+ SCHEMA = "last30days-x-posts/1"
57
+ ID_PREFIX = "XHOST"
58
+
59
+ # Input bounds. Named so the recipe and the receipts can quote them.
60
+ MAX_BYTES = 8 * 1024 * 1024
61
+ MAX_CALLS = 20
62
+ MAX_ROWS_PER_CALL = 500
63
+ MAX_ROWS_TOTAL = 1000
64
+ MAX_TEXT_CHARS = 10_000
65
+ MAX_AGE_SECONDS = 6 * 60 * 60
66
+ MAX_ID_DIGITS = 20
67
+
68
+ LANES = ("topic", "from", "mention", "related")
69
+ STATUSES = ("ok", "partial", "error")
70
+ ROW_FIELDS = frozenset(
71
+ {"id", "author_handle", "created_at", "text", "likes", "reposts", "replies", "quotes"}
72
+ )
73
+ COUNTERS = (
74
+ "out-of-window",
75
+ "missing-id-text",
76
+ "uncitable",
77
+ "date-mismatch",
78
+ "handle-mismatch",
79
+ "duplicate",
80
+ "lane-mismatch",
81
+ "truncated",
82
+ "extra-fields",
83
+ )
84
+
85
+ # Engine-authored fixed outcome details. The envelope's own error text
86
+ # never becomes a detail string.
87
+ _CONNECTOR_LABEL = "X connector"
88
+ _NATIVE_LABEL = "Grok Bot X"
89
+ DETAIL_CREDITS = f"{_CONNECTOR_LABEL} reported no credits"
90
+ DETAIL_NOT_CONNECTED = f"{_CONNECTOR_LABEL} not connected"
91
+ DETAIL_UNAVAILABLE = f"{_CONNECTOR_LABEL} unavailable"
92
+ DETAIL_ERROR = f"{_CONNECTOR_LABEL} error"
93
+ DETAIL_GENERATED = f"{_CONNECTOR_LABEL} rows rejected (id sequence looks generated)"
94
+ DETAIL_PARTIAL = f"{_CONNECTOR_LABEL} returned partial results"
95
+ DETAIL_NOT_PASSED = "connector result not passed"
96
+
97
+ # The envelope's free-text ``provider`` reaches output only through this
98
+ # allowlist: ``x-native`` (Grok Bot's built-in X tools) gets its own label,
99
+ # and anything else keeps the connector wording.
100
+ PROVIDER_NATIVE = "x-native"
101
+ PROVENANCE_NATIVE = "native"
102
+ PROVENANCE_CONNECTOR = "connector"
103
+ PROVENANCE_LABELS = {
104
+ PROVENANCE_CONNECTOR: _CONNECTOR_LABEL,
105
+ PROVENANCE_NATIVE: _NATIVE_LABEL,
106
+ }
107
+
108
+ CATEGORY_CREDITS = "credits"
109
+ CATEGORY_NOT_CONNECTED = "not-connected"
110
+ CATEGORY_UNAVAILABLE = "unavailable"
111
+ CATEGORY_WINDOW_UNSUPPORTED = "window-unsupported"
112
+ _CATEGORY_GENERATED = "generated"
113
+
114
+ _REMEDY = (
115
+ "Rewrite the file to the last30days-x-posts/1 shape, or re-run without --x-posts."
116
+ )
117
+
118
+ # Directories the envelope must never be read from: the engine's own config
119
+ # dir (which holds .env) and the credential stores of the X tooling and the
120
+ # usual shell neighbours. Checked after realpath so a symlink cannot reach in.
121
+ _CREDENTIAL_STORES = (
122
+ "~/.xurl",
123
+ "~/.grok",
124
+ "~/.config/last30days",
125
+ "~/.config/gh",
126
+ "~/.aws",
127
+ "~/.ssh",
128
+ "~/.netrc",
129
+ )
130
+
131
+ # Snowflake ids predate nothing before X's epoch; a decode outside
132
+ # [2010-11-04, now + 1 day] is not a real post id.
133
+ _SNOWFLAKE_FLOOR = datetime(2010, 11, 4, tzinfo=timezone.utc)
134
+
135
+ # C0 and C1 control characters. Text keeps newlines and tabs; handles and the
136
+ # error field keep nothing.
137
+ _CONTROL_ANY_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
138
+ _CONTROL_TEXT_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
139
+ _PLACEHOLDER_HANDLES = frozenset(
140
+ {"unknown", "n/a", "na", "none", "null", "user", "anonymous", "placeholder", "handle"}
141
+ )
142
+ _STATUS_URL_RE = re.compile(
143
+ r"^https?://(?:www\.|mobile\.)?(?:x\.com|twitter\.com)/(?P<handle>[A-Za-z0-9_]{1,15})/status(?:es)?/(?P<id>\d+)",
144
+ re.IGNORECASE,
145
+ )
146
+ _TOPIC_STRIP = "\"'“”‘’ \t"
147
+
148
+
149
+ class EnvelopeContractError(Exception):
150
+ """The envelope failed its contract: unreadable, out of bounds, malformed,
151
+ stale, off-topic, or outside the research window. The CLI maps this to
152
+ exit code 2. The message names the path and the offending field only."""
153
+
154
+ def __init__(self, message: str) -> None:
155
+ super().__init__(message)
156
+ self.message = message
157
+
158
+
159
+ @dataclass
160
+ class EnvelopeCall:
161
+ """One validated handle-lane call (``from``, ``mention``, or ``related``)."""
162
+
163
+ index: int
164
+ lane: str
165
+ handles: list[str]
166
+ posts: list[dict[str, Any]]
167
+
168
+
169
+ @dataclass
170
+ class Envelope:
171
+ """A validated ``--x-posts`` file, ready to be served once."""
172
+
173
+ path: str
174
+ sha256: str
175
+ status: str
176
+ error_category: str
177
+ provider: str
178
+ window: tuple[str, str]
179
+ topic_items: list[dict[str, Any]]
180
+ lane_calls: list[EnvelopeCall]
181
+ counters: dict[str, int]
182
+ accepted: int
183
+ total: int
184
+ lane_counts: dict[str, int]
185
+ call_lanes: list[str]
186
+ warnings: list[str] = field(default_factory=list)
187
+ notes: list[str] = field(default_factory=list)
188
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
189
+ _topic_served: bool = field(default=False, repr=False, compare=False)
190
+ _lanes_served: bool = field(default=False, repr=False, compare=False)
191
+
192
+ def take_topic(self) -> list[dict[str, Any]] | None:
193
+ """Topic-lane rows on the first call; ``None`` once consumed."""
194
+ with self._lock:
195
+ if self._topic_served:
196
+ return None
197
+ self._topic_served = True
198
+ return list(self.topic_items)
199
+
200
+ def take_lanes(self) -> list[EnvelopeCall] | None:
201
+ """Handle-lane calls on the first call; ``None`` once consumed."""
202
+ with self._lock:
203
+ if self._lanes_served:
204
+ return None
205
+ self._lanes_served = True
206
+ return list(self.lane_calls)
207
+
208
+ @property
209
+ def provenance(self) -> str:
210
+ """``native`` for a Grok Bot built-in X envelope, else ``connector``."""
211
+ if self.provider.lower() == PROVIDER_NATIVE:
212
+ return PROVENANCE_NATIVE
213
+ return PROVENANCE_CONNECTOR
214
+
215
+ def outcome(self) -> tuple[schema.RunOutcomeState, str] | None:
216
+ """The fixed source outcome for a non-ok envelope, else ``None``."""
217
+ if self.status == "error":
218
+ state, detail = _error_outcome(self.error_category)
219
+ return state, self._labelled(detail)
220
+ if self.status == "partial":
221
+ detail = DETAIL_PARTIAL
222
+ if self.error_category:
223
+ detail += f": {self.error_category}"
224
+ if self.call_lanes:
225
+ detail += f" (calls: {', '.join(self.call_lanes)})"
226
+ return schema.PARTIAL, self._labelled(detail)
227
+ return None
228
+
229
+ def _labelled(self, detail: str) -> str:
230
+ if self.provenance == PROVENANCE_NATIVE:
231
+ return detail.replace(_CONNECTOR_LABEL, _NATIVE_LABEL, 1)
232
+ return detail
233
+
234
+ def receipt(self) -> str:
235
+ dropped = ", ".join(
236
+ f"{name} {count}" for name, count in self.counters.items() if count
237
+ ) or "none"
238
+ lanes = ", ".join(f"{lane} {self.lane_counts.get(lane, 0)}" for lane in LANES)
239
+ line = (
240
+ f"host-fetched X: accepted {self.accepted} of {self.total} "
241
+ f"(dropped: {dropped}); lanes: {lanes}"
242
+ )
243
+ if self.status != "ok":
244
+ line += f"; status {self.status}"
245
+ if self.error_category:
246
+ line += f" ({self.error_category})"
247
+ if self.notes:
248
+ line += "; notes: " + "; ".join(self.notes)
249
+ return line
250
+
251
+
252
+ def _error_outcome(category: str) -> tuple[schema.RunOutcomeState, str]:
253
+ if category == CATEGORY_CREDITS:
254
+ return schema.PAYMENT_REQUIRED, DETAIL_CREDITS
255
+ if category == CATEGORY_NOT_CONNECTED:
256
+ return health.ERROR, DETAIL_NOT_CONNECTED
257
+ if category == CATEGORY_UNAVAILABLE:
258
+ return health.ERROR, DETAIL_UNAVAILABLE
259
+ if category == _CATEGORY_GENERATED:
260
+ return health.ERROR, DETAIL_GENERATED
261
+ return health.ERROR, DETAIL_ERROR
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Helpers
266
+ # ---------------------------------------------------------------------------
267
+
268
+
269
+ def _stat(path: Path) -> os.stat_result:
270
+ """Seam for tests: the size and type check runs before any read."""
271
+ return os.stat(path)
272
+
273
+
274
+ def _log(msg: str) -> None:
275
+ log.source_log("x", msg, tty_only=False)
276
+
277
+
278
+ def _protected_roots() -> list[Path]:
279
+ roots: list[Path] = []
280
+ for candidate in (env.CONFIG_DIR, getattr(env, "CONFIG_FILE", None)):
281
+ if candidate:
282
+ roots.append(Path(candidate))
283
+ for store in _CREDENTIAL_STORES:
284
+ roots.append(Path(store).expanduser())
285
+ return [Path(os.path.realpath(root)) for root in roots]
286
+
287
+
288
+ def _inside_protected(real: Path) -> bool:
289
+ for root in _protected_roots():
290
+ if real == root or root in real.parents:
291
+ return True
292
+ return False
293
+
294
+
295
+ def _normalize_topic(value: str) -> str:
296
+ cleaned = _CONTROL_ANY_RE.sub("", str(value or ""))
297
+ cleaned = cleaned.strip(_TOPIC_STRIP).casefold()
298
+ return " ".join(cleaned.split())
299
+
300
+
301
+ def _parse_day(value: Any) -> date | None:
302
+ if not isinstance(value, str):
303
+ return None
304
+ try:
305
+ return date.fromisoformat(value.strip()[:10])
306
+ except ValueError:
307
+ return None
308
+
309
+
310
+ def _parse_iso(value: str) -> datetime | None:
311
+ text = value.strip()
312
+ if text.endswith(("Z", "z")):
313
+ text = text[:-1] + "+00:00"
314
+ try:
315
+ parsed = datetime.fromisoformat(text)
316
+ except ValueError:
317
+ return None
318
+ if parsed.tzinfo is None:
319
+ parsed = parsed.replace(tzinfo=timezone.utc)
320
+ return parsed
321
+
322
+
323
+ def _category(raw: str) -> str:
324
+ """The short error class: first token, lowercased, [a-z0-9-] only."""
325
+ cleaned = _CONTROL_ANY_RE.sub("", raw or "").strip().lower()
326
+ if not cleaned:
327
+ return ""
328
+ token = cleaned.split()[0]
329
+ token = re.sub(r"[^a-z0-9-]", "", token)
330
+ return token[:40]
331
+
332
+
333
+ def _clean_text(value: str) -> str:
334
+ """Strip C0/C1 controls (newlines and tabs survive) and defang markup.
335
+
336
+ Envelope text is model-written and post-authored, so the two Markdown
337
+ constructs that stay active in the saved report are neutralised without
338
+ hiding the characters: a link tail ``](`` becomes ``]\\(`` (renders as
339
+ the same text, never as a link) and an HTML comment opener ``<!--``
340
+ becomes ``<! --`` (never a comment, so a pasted META marker cannot be
341
+ promoted). Raw tags are left to the HTML renderer's escaping.
342
+ """
343
+ text = value.replace("\r\n", "\n").replace("\r", "\n")
344
+ text = _CONTROL_TEXT_RE.sub("", text)
345
+ return text.replace("](", "]\\(").replace("<!--", "<! --")
346
+
347
+
348
+ def _int(value: Any) -> int:
349
+ if isinstance(value, bool):
350
+ return 0
351
+ try:
352
+ return max(0, int(value))
353
+ except (TypeError, ValueError):
354
+ return 0
355
+
356
+
357
+ def _post_id(value: Any) -> str:
358
+ if isinstance(value, bool):
359
+ return ""
360
+ if isinstance(value, int):
361
+ candidate = str(value)
362
+ elif isinstance(value, str):
363
+ candidate = value.strip()
364
+ else:
365
+ return ""
366
+ if not candidate.isdigit() or len(candidate) > MAX_ID_DIGITS:
367
+ return ""
368
+ return candidate
369
+
370
+
371
+ def _handle(value: Any) -> str:
372
+ if not isinstance(value, str) or _CONTROL_ANY_RE.search(value):
373
+ return ""
374
+ handle = _clean_handle(value)
375
+ if handle.lower() in _PLACEHOLDER_HANDLES:
376
+ return ""
377
+ return handle
378
+
379
+
380
+ def _clean_handles(value: Any) -> list[str] | None:
381
+ """Grammar-valid, lowercased handles; ``None`` when any entry fails."""
382
+ if not isinstance(value, list):
383
+ return None
384
+ cleaned: list[str] = []
385
+ for raw in value:
386
+ handle = _handle(raw)
387
+ if not handle:
388
+ return None
389
+ if handle.lower() not in cleaned:
390
+ cleaned.append(handle.lower())
391
+ return cleaned
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # Reader
396
+ # ---------------------------------------------------------------------------
397
+
398
+
399
+ # Clock skew a host may legitimately show; anything further ahead is a
400
+ # stamp chosen to outlive the six-hour freshness gate.
401
+ FUTURE_SKEW_SECONDS = 5 * 60
402
+
403
+
404
+ def _generated_in_future(value: Any) -> bool:
405
+ """True when ``generated_at`` is more than ``FUTURE_SKEW_SECONDS`` ahead of now."""
406
+ try:
407
+ stamp = datetime.fromisoformat(str(value))
408
+ except (TypeError, ValueError):
409
+ return False
410
+ if stamp.tzinfo is None:
411
+ stamp = stamp.replace(tzinfo=timezone.utc)
412
+ ahead = stamp.astimezone(timezone.utc) - datetime.now(timezone.utc)
413
+ return ahead.total_seconds() > FUTURE_SKEW_SECONDS
414
+
415
+
416
+ def read(
417
+ path: str | os.PathLike[str],
418
+ window: tuple[str, str],
419
+ topic: str,
420
+ handles: list[str] | None = None,
421
+ related: list[str] | None = None,
422
+ ) -> Envelope:
423
+ """Validate and ingest a ``--x-posts`` file for this run.
424
+
425
+ ``window`` is the engine's ``(from_date, to_date)``; ``topic`` is the run
426
+ topic (or comparison entity); ``handles`` are the run's ``--x-handle``
427
+ handles and ``related`` its ``--x-related`` handles, which together bound
428
+ what a call's ``handles`` may claim.
429
+
430
+ Raises ``EnvelopeContractError`` (exit 2) for every fail-closed case.
431
+ """
432
+ raw_path = os.fspath(path)
433
+
434
+ def fail(what: str) -> EnvelopeContractError:
435
+ return EnvelopeContractError(f"--x-posts file {raw_path} {what}. {_REMEDY}")
436
+
437
+ real = Path(os.path.realpath(raw_path))
438
+ try:
439
+ info = _stat(real)
440
+ except OSError as exc:
441
+ raise fail(f"cannot be read ({type(exc).__name__})") from None
442
+ if not stat.S_ISREG(info.st_mode):
443
+ raise fail("is not a regular file")
444
+ if real.suffix.lower() != ".json":
445
+ raise fail("must be a .json file")
446
+ if _inside_protected(real):
447
+ raise fail("is inside a configuration or credential directory")
448
+ if info.st_size > MAX_BYTES:
449
+ raise fail(f"exceeds the {MAX_BYTES // (1024 * 1024)} MiB limit")
450
+
451
+ try:
452
+ data = real.read_bytes()
453
+ except OSError as exc:
454
+ raise fail(f"cannot be read ({type(exc).__name__})") from None
455
+ digest = hashlib.sha256(data).hexdigest()
456
+ try:
457
+ text = data.decode("utf-8")
458
+ except UnicodeDecodeError:
459
+ raise fail("is not valid UTF-8") from None
460
+ try:
461
+ payload = json.loads(text)
462
+ except (RecursionError, MemoryError):
463
+ raise fail("is too deeply nested or too large to parse") from None
464
+ except ValueError:
465
+ raise fail("is not valid JSON") from None
466
+
467
+ if not isinstance(payload, dict):
468
+ raise fail("must be a JSON object")
469
+ if payload.get("schema") != SCHEMA:
470
+ raise fail('has a missing or unsupported "schema" field')
471
+ status = payload.get("status")
472
+ if status not in STATUSES:
473
+ raise fail('has a "status" field that is not ok, partial, or error')
474
+ if not env.is_timestamp_fresh(payload.get("generated_at"), MAX_AGE_SECONDS):
475
+ raise fail(
476
+ 'has a "generated_at" field that is missing, malformed, or older than '
477
+ f"{MAX_AGE_SECONDS // 3600} hours"
478
+ )
479
+ if _generated_in_future(payload.get("generated_at")):
480
+ raise fail('has a "generated_at" field in the future')
481
+ if _normalize_topic(payload.get("topic")) != _normalize_topic(topic) or not _normalize_topic(topic):
482
+ raise fail('has a "topic" field that does not match this run\'s topic')
483
+
484
+ from_date, to_date = window
485
+ window_raw = payload.get("window")
486
+ if not isinstance(window_raw, dict):
487
+ raise fail('has a missing or malformed "window" field')
488
+ window_from = _parse_day(window_raw.get("from"))
489
+ window_to = _parse_day(window_raw.get("to"))
490
+ if window_from is None or window_to is None:
491
+ raise fail('has a "window" field without valid from/to dates')
492
+ engine_from = _parse_day(from_date) or date.min
493
+ engine_to = _parse_day(to_date) or date.max
494
+ if window_to < engine_from:
495
+ raise fail('has a "window" that ends before the research window starts')
496
+ warnings: list[str] = []
497
+ if window_from > engine_from or window_to < engine_to:
498
+ warnings.append(
499
+ f"host-fetched X window {window_from.isoformat()}..{window_to.isoformat()} "
500
+ f"is narrower than the research window {from_date}..{to_date}; "
501
+ "the research window is authoritative"
502
+ )
503
+
504
+ provider = _CONTROL_ANY_RE.sub("", str(payload.get("provider") or ""))[:64].strip()
505
+
506
+ error_raw = payload.get("error")
507
+ if error_raw is not None and not isinstance(error_raw, str):
508
+ raise fail('has an "error" field that is not a string')
509
+ if error_raw and ("\n" in error_raw or "\r" in error_raw):
510
+ raise fail('has an "error" field that is not a single line')
511
+ error_category = _category(error_raw or "")
512
+ if error_raw and log.is_debug():
513
+ _log(f"envelope error text (debug only): {_CONTROL_ANY_RE.sub('', error_raw)[:500]}")
514
+
515
+ calls_raw = payload.get("calls")
516
+ if not isinstance(calls_raw, list):
517
+ raise fail('has a "calls" field that is not a list')
518
+ if len(calls_raw) > MAX_CALLS:
519
+ raise fail(f'has more than {MAX_CALLS} entries in "calls"')
520
+ if not calls_raw and status == "ok":
521
+ raise fail('has an empty "calls" list with status ok')
522
+
523
+ allowed = {h.lower() for h in (_clean_handles(list(handles or [])) or [])}
524
+ related_set = {h.lower() for h in (_clean_handles(list(related or [])) or [])}
525
+ allowed |= related_set
526
+
527
+ counters: dict[str, int] = {name: 0 for name in COUNTERS}
528
+ lane_counts: dict[str, int] = {lane: 0 for lane in LANES}
529
+ notes: list[str] = []
530
+ call_lanes: list[str] = []
531
+ topic_items: list[dict[str, Any]] = []
532
+ lane_calls: list[EnvelopeCall] = []
533
+ seen_ids: set[str] = set()
534
+ kept_ids: list[str] = []
535
+ total = 0
536
+ now_plus = datetime.now(timezone.utc) + timedelta(days=1)
537
+
538
+ for index, call in enumerate(calls_raw, start=1):
539
+ if not isinstance(call, dict):
540
+ raise fail(f"has a call {index} that is not an object")
541
+ posts = call.get("posts")
542
+ if not isinstance(posts, list) or any(not isinstance(row, dict) for row in posts):
543
+ raise fail(f'has a call {index} whose "posts" is not a list of objects')
544
+ if len(posts) > MAX_ROWS_PER_CALL:
545
+ raise fail(f"has a call {index} with more than {MAX_ROWS_PER_CALL} rows")
546
+ total += len(posts)
547
+ if total > MAX_ROWS_TOTAL:
548
+ raise fail(f"has more than {MAX_ROWS_TOTAL} rows in total")
549
+
550
+ lane_raw = call.get("lane")
551
+ lane = lane_raw.strip().lower() if isinstance(lane_raw, str) else ""
552
+ call_handles = _clean_handles(call.get("handles"))
553
+ if lane not in LANES:
554
+ if lane_raw is not None:
555
+ counters["lane-mismatch"] += 1
556
+ notes.append(f"call {index}: unknown lane served as topic")
557
+ lane = "topic"
558
+ call_handles = []
559
+ elif lane != "topic":
560
+ valid = bool(call_handles) and set(call_handles) <= allowed
561
+ if valid and lane == "related":
562
+ valid = set(call_handles) <= related_set
563
+ if not valid:
564
+ counters["lane-mismatch"] += 1
565
+ notes.append(
566
+ f"call {index}: {lane} lane served as topic "
567
+ "(handles are not in --x-handle/--x-related)"
568
+ )
569
+ lane = "topic"
570
+ call_handles = []
571
+ else:
572
+ call_handles = []
573
+ call_lanes.append(lane)
574
+
575
+ kept: list[dict[str, Any]] = []
576
+ for row in posts:
577
+ item = _ingest_row(
578
+ row, topic=topic, from_date=from_date, to_date=to_date,
579
+ now_plus=now_plus, seen_ids=seen_ids, counters=counters,
580
+ )
581
+ if item is None:
582
+ continue
583
+ author = item["author_handle"].lower()
584
+ if lane == "from" or lane == "related":
585
+ if author not in call_handles:
586
+ counters["lane-mismatch"] += 1
587
+ seen_ids.discard(item["post_id"])
588
+ continue
589
+ elif lane == "mention":
590
+ if author in call_handles or any(
591
+ is_own_post(item["url"], handle) for handle in call_handles
592
+ ):
593
+ counters["lane-mismatch"] += 1
594
+ seen_ids.discard(item["post_id"])
595
+ continue
596
+ kept.append(item)
597
+ kept_ids.append(item["post_id"])
598
+
599
+ lane_counts[lane] += len(kept)
600
+ if lane == "topic":
601
+ topic_items.extend(kept)
602
+ else:
603
+ lane_calls.append(EnvelopeCall(index=index, lane=lane, handles=call_handles, posts=kept))
604
+
605
+ accepted = len(kept_ids)
606
+ if status == "error":
607
+ # An error envelope carries no evidence: the outcome is the story.
608
+ topic_items, lane_calls = [], []
609
+ lane_counts = {lane: 0 for lane in LANES}
610
+ accepted = 0
611
+ elif kept_ids and _looks_generated(kept_ids):
612
+ status = "error"
613
+ error_category = _CATEGORY_GENERATED
614
+ topic_items, lane_calls = [], []
615
+ lane_counts = {lane: 0 for lane in LANES}
616
+ accepted = 0
617
+ notes.append("id sequence looks generated; envelope rejected")
618
+
619
+ for position, item in enumerate(
620
+ [*topic_items, *(post for call in lane_calls for post in call.posts)], start=1
621
+ ):
622
+ item["id"] = f"{ID_PREFIX}{position}"
623
+
624
+ envelope = Envelope(
625
+ path=raw_path,
626
+ sha256=digest,
627
+ status=status,
628
+ error_category=error_category,
629
+ provider=provider,
630
+ window=(window_from.isoformat(), window_to.isoformat()),
631
+ topic_items=topic_items,
632
+ lane_calls=lane_calls,
633
+ counters=counters,
634
+ accepted=accepted,
635
+ total=total,
636
+ lane_counts=lane_counts,
637
+ call_lanes=call_lanes,
638
+ warnings=warnings,
639
+ notes=notes,
640
+ )
641
+ _log(envelope.receipt())
642
+ for warning in warnings:
643
+ _log(warning)
644
+ return envelope
645
+
646
+
647
+ def _ingest_row(
648
+ row: dict[str, Any],
649
+ *,
650
+ topic: str,
651
+ from_date: str,
652
+ to_date: str,
653
+ now_plus: datetime,
654
+ seen_ids: set[str],
655
+ counters: dict[str, int],
656
+ ) -> dict[str, Any] | None:
657
+ """Rebuild one flat row into the engine's X item shape, or drop it.
658
+
659
+ Returns ``None`` after incrementing the matching counter. The citation
660
+ URL is always engine-built from the id and the validated handle.
661
+ """
662
+ if set(row) - ROW_FIELDS:
663
+ counters["extra-fields"] += 1
664
+
665
+ post_id = _post_id(row.get("id"))
666
+ text_raw = row.get("text")
667
+ text = _clean_text(text_raw) if isinstance(text_raw, str) else ""
668
+ if not post_id or not text.strip():
669
+ counters["missing-id-text"] += 1
670
+ return None
671
+ text = text.strip()
672
+ if len(text) > MAX_TEXT_CHARS:
673
+ text = text[:MAX_TEXT_CHARS]
674
+ counters["truncated"] += 1
675
+
676
+ decoded = _decode_snowflake(post_id)
677
+ if decoded is None or decoded < _SNOWFLAKE_FLOOR or decoded > now_plus:
678
+ counters["out-of-window"] += 1
679
+ return None
680
+ snowflake_day = decoded.date()
681
+
682
+ created_raw = row.get("created_at")
683
+ if isinstance(created_raw, str) and created_raw.strip():
684
+ created = _parse_iso(created_raw)
685
+ if created is not None and abs((created.date() - snowflake_day).days) > 1:
686
+ counters["date-mismatch"] += 1
687
+ return None
688
+
689
+ day = snowflake_day.isoformat()
690
+ if (from_date and day < from_date) or (to_date and day > to_date):
691
+ counters["out-of-window"] += 1
692
+ return None
693
+
694
+ if post_id in seen_ids:
695
+ counters["duplicate"] += 1
696
+ return None
697
+ seen_ids.add(post_id)
698
+
699
+ handle = _handle(row.get("author_handle"))
700
+ url_raw = row.get("url")
701
+ if isinstance(url_raw, str) and url_raw.strip():
702
+ match = _STATUS_URL_RE.match(url_raw.strip())
703
+ url_handle = match.group("handle").lower() if match else ""
704
+ consistent = bool(match) and match.group("id") == post_id and (
705
+ url_handle == "i" or not handle or url_handle == handle.lower()
706
+ )
707
+ if not consistent:
708
+ counters["handle-mismatch"] += 1
709
+ handle = ""
710
+
711
+ if handle:
712
+ url = f"https://x.com/{handle}/status/{post_id}"
713
+ else:
714
+ counters["uncitable"] += 1
715
+ url = f"https://x.com/i/status/{post_id}"
716
+
717
+ return {
718
+ "id": "",
719
+ "text": text,
720
+ "url": url,
721
+ "author_handle": handle,
722
+ "date": day,
723
+ "engagement": {
724
+ "likes": _int(row.get("likes")),
725
+ "reposts": _int(row.get("reposts")),
726
+ "replies": _int(row.get("replies")),
727
+ "quotes": _int(row.get("quotes")),
728
+ },
729
+ "mentioned_handles": leading_mentions(text),
730
+ "why_relevant": "",
731
+ "relevance": _compute_relevance(topic, text) if topic else 0.5,
732
+ "post_id": post_id,
733
+ }