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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- 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
|
+
}
|