Skip to content

Commit 049030e

Browse files
v1.7.1: FxTwitter Article enrichment — read+analyze auto-resolve Article bodies
Closes the Article-blindness gap surfaced when analyzing @longevityboris's 2056280764447113305 ("Two species.", 204K imp, 17 likes, 1 profile click): xmaster previously saw only the t.co wrapper and preflight was scoring an empty string. Now it scores the full 6,387-char Article body. ## What changed - New `src/providers/fxtwitter.rs` — soft-dependency client for api.fxtwitter.com. Returns `Article { id, title, body, body_chars, cover_image_url }` flattened from Draft.js blocks (header-two → "## ", unordered-list-item → "- ", blockquote → "> ", etc.). 2.5s timeout, graceful degradation on any failure. - `xmaster read <id>`: when the tweet text looks like a single t.co URL, auto-fetches the Article body via FxTwitter and surfaces it in the output as an optional `article` field. Skips when `settings.disable_fxtwitter = true`. - `xmaster analyze <id_or_url>`: auto-detects tweet IDs (15-25 digit numeric) and X URLs (`x.com/.../status/<id>`), fetches the tweet, resolves Article bodies through FxTwitter, then runs preflight on the actual content. Free-form text input still works unchanged. No new flag — pure friction reduction. - `Settings.disable_fxtwitter: bool` (default false) — opt-out for users who want strict-v2-API-only behavior. Set via `xmaster config set settings.disable_fxtwitter true`. - preflight.rs hashtag counter — fixed false positive on markdown header prefixes from flattened Article bodies. Now counts `#X` only where X is alphanumeric AND previous char isn't `#`, so `## Heading` doesn't trigger excessive_hashtags but `#rust #cli` still does. ## Verified live on Boris's "Two species." Article (id 2056280764447113305) Before v1.7.1: `xmaster read 2056280764447113305` → text: "https://t.co/MAoQl3DbpC" (article body invisible) After v1.7.1: `xmaster read 2056280764447113305` → article: { id: "2056263966406451201", title: "Two species.", body_chars: 6373, body: "AI is making most people dumber..." (full 6,373-char body) } `xmaster analyze 2056280764447113305` → scores the full Article (D, 45) with real findings: weak_hook_no_weight ("Two species." carries no number/named-entity/status-verb), long_form_too_long (6,387 > 5,000), negative_sentiment. ## Why FxTwitter, not the private GraphQL - v2 API doesn't expose Article bodies (only t.co + entities.urls) - Private GraphQL `ArticleEntityGet`-style endpoints rotate operation IDs every X deploy (xmaster's `article draft` path already feels this pain) - FxTwitter (community service powering Discord/Telegram unfurls) returns the Article body as Draft.js blocks via `api.fxtwitter.com/i/status/<id>` with no auth, sub-second response, MIT-licensed, actively maintained - Soft dep: timeout 2.5s, opt-out flag, falls back to v2 response on any failure — never blocks the user ## Tests 99 unit (+2 for the hashtag-markdown fix) + 29 integration pass; clippy -D warnings clean. Two new fxtwitter.rs unit tests for block flattening and wrapper detection.
1 parent 444a3e8 commit 049030e

8 files changed

Lines changed: 351 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "xmaster"
3-
version = "1.7.0"
3+
version = "1.7.1"
44
edition = "2021"
55
description = "Enterprise-grade X/Twitter CLI — post, reply, like, retweet, DM, search, and more"
66
license = "MIT"

src/commands/analyze.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,19 @@ pub async fn execute(
178178
goal: Option<&str>,
179179
reply_to: Option<&str>,
180180
) -> Result<(), XmasterError> {
181+
// Auto-detect: if the input is a tweet ID or X URL, fetch the actual
182+
// content. For Article wrappers (text = single t.co), enrich via
183+
// FxTwitter and analyze the Article body instead of the t.co link.
184+
// Friction-free: agents don't need a separate command to score a post.
185+
let resolved_text = if let Some(id) = parse_tweet_reference(text) {
186+
match resolve_post_content(&app, &id).await {
187+
Ok(content) => content,
188+
Err(_) => text.to_string(), // fall back to raw text on failure
189+
}
190+
} else {
191+
text.to_string()
192+
};
193+
181194
let premium = app.config.account.premium;
182195
let voice = if app.config.style.voice.is_empty() { None } else { Some(app.config.style.voice.clone()) };
183196
let mode = reply_to.map(|_| PostMode::Reply);
@@ -188,8 +201,56 @@ pub async fn execute(
188201
author_voice: voice,
189202
..Default::default()
190203
};
191-
let result = preflight::analyze(text, &ctx);
204+
let result = preflight::analyze(&resolved_text, &ctx);
192205
let display = AnalyzeDisplay { result, premium };
193206
output::render(format, &display, None);
194207
Ok(())
195208
}
209+
210+
/// Detects whether the input refers to a tweet (numeric ID or X URL).
211+
/// Returns the bare tweet ID if so. None for free-form text.
212+
fn parse_tweet_reference(input: &str) -> Option<String> {
213+
let trimmed = input.trim();
214+
// URL form: https://x.com/<user>/status/<id> or twitter.com variant
215+
if let Some(idx) = trimmed.find("/status/") {
216+
let tail = &trimmed[idx + "/status/".len()..];
217+
let id: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
218+
if id.len() >= 15 {
219+
return Some(id);
220+
}
221+
}
222+
// Numeric ID form (X tweet IDs are 18-19 digits today; allow 15+)
223+
if trimmed.len() >= 15
224+
&& trimmed.len() <= 25
225+
&& trimmed.chars().all(|c| c.is_ascii_digit())
226+
{
227+
return Some(trimmed.to_string());
228+
}
229+
None
230+
}
231+
232+
/// Fetch a post by ID. If the body is an Article wrapper, enrich with
233+
/// FxTwitter so preflight scores the actual Article content, not the t.co.
234+
async fn resolve_post_content(
235+
app: &Arc<AppContext>,
236+
tweet_id: &str,
237+
) -> Result<String, XmasterError> {
238+
let api = crate::providers::xapi::XApi::new(app.clone());
239+
let tweet = api.get_tweet(tweet_id).await?;
240+
if !app.config.settings.disable_fxtwitter
241+
&& crate::providers::fxtwitter::text_looks_like_article_wrapper(&tweet.text)
242+
{
243+
if let Ok(Some(article)) = crate::providers::fxtwitter::fetch_article(&tweet.id).await {
244+
// Title + body — gives preflight everything it needs to score the
245+
// long-form correctly (hook strength, payoff density, POV, etc.).
246+
let mut combined = String::new();
247+
if !article.title.is_empty() {
248+
combined.push_str(&article.title);
249+
combined.push_str("\n\n");
250+
}
251+
combined.push_str(&article.body);
252+
return Ok(combined);
253+
}
254+
}
255+
Ok(tweet.text)
256+
}

src/commands/read_post.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::context::AppContext;
33
use crate::errors::XmasterError;
44
use crate::intel::store::IntelStore;
55
use crate::output::{self, OutputFormat, Tableable};
6+
use crate::providers::fxtwitter::{self, Article};
67
use crate::providers::xapi::XApi;
78
use serde::Serialize;
89
use std::sync::Arc;
@@ -21,6 +22,10 @@ struct PostDisplay {
2122
date: Option<String>,
2223
#[serde(skip_serializing_if = "Vec::is_empty")]
2324
media_urls: Vec<String>,
25+
/// X Article body, surfaced via FxTwitter when the tweet text is just a
26+
/// t.co wrapper for an Article. `None` for regular tweets.
27+
#[serde(skip_serializing_if = "Option::is_none")]
28+
article: Option<Article>,
2429
}
2530

2631
impl Tableable for PostDisplay {
@@ -41,6 +46,14 @@ impl Tableable for PostDisplay {
4146
for url in &self.media_urls {
4247
table.add_row(vec!["Media", url]);
4348
}
49+
if let Some(ref art) = self.article {
50+
table.add_row(vec!["Article ID", &art.id]);
51+
table.add_row(vec!["Article Title", &art.title]);
52+
table.add_row(vec![
53+
"Article Body".to_string(),
54+
format!("{} chars\n\n{}", art.body_chars, &art.body),
55+
]);
56+
}
4457
table
4558
}
4659
}
@@ -57,6 +70,17 @@ pub async fn execute(
5770
let _ = store.record_discovered_post("read", &tweet);
5871
}
5972

73+
// Article enrichment: if the tweet text looks like a t.co Article wrapper,
74+
// try FxTwitter (no auth, ~2.5s timeout, graceful degradation). Skip when
75+
// disabled in config.
76+
let article = if !ctx.config.settings.disable_fxtwitter
77+
&& fxtwitter::text_looks_like_article_wrapper(&tweet.text)
78+
{
79+
fxtwitter::fetch_article(&tweet.id).await.ok().flatten()
80+
} else {
81+
None
82+
};
83+
6084
let metrics = tweet.public_metrics.as_ref();
6185
let display = PostDisplay {
6286
id: tweet.id,
@@ -72,6 +96,7 @@ pub async fn execute(
7296
bookmarks: metrics.map(|m| m.bookmark_count).unwrap_or(0),
7397
date: tweet.created_at,
7498
media_urls: tweet.media_urls,
99+
article,
75100
};
76101
output::render(format, &display, None);
77102
Ok(())

src/config.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,21 @@ pub struct Keys {
103103
pub struct Settings {
104104
#[serde(default = "default_timeout")]
105105
pub timeout: u64,
106+
/// Disable FxTwitter Article-enrichment fallback. Default false (enabled).
107+
/// FxTwitter is the third-party service xmaster uses to read X Article
108+
/// bodies — the public v2 API doesn't expose them. Set true to opt out;
109+
/// `xmaster read`/`metrics`/`timeline` will still work but won't surface
110+
/// Article content.
111+
/// Set via: xmaster config set settings.disable_fxtwitter true
112+
#[serde(default)]
113+
pub disable_fxtwitter: bool,
106114
}
107115

108116
impl Default for Settings {
109117
fn default() -> Self {
110118
Self {
111119
timeout: default_timeout(),
120+
disable_fxtwitter: false,
112121
}
113122
}
114123
}

src/intel/preflight.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1001,7 +1001,28 @@ fn extract_features(text: &str) -> FeatureVector {
10011001
let has_link = text.contains("http://") || text.contains("https://");
10021002
let link_position = if has_link { Some("body".into()) } else { None };
10031003

1004-
let hashtag_count = text.matches('#').count();
1004+
// Count real X hashtags only: `#` followed by alphanumeric (not space,
1005+
// not another `#`). Skips markdown `## ` header prefixes that surface in
1006+
// long-form / Article bodies fetched via FxTwitter.
1007+
let hashtag_count = {
1008+
let chars: Vec<char> = text.chars().collect();
1009+
let mut count = 0;
1010+
for (i, &c) in chars.iter().enumerate() {
1011+
if c != '#' {
1012+
continue;
1013+
}
1014+
// Previous char must NOT be `#` (skip ## / ### markdown)
1015+
if i > 0 && chars[i - 1] == '#' {
1016+
continue;
1017+
}
1018+
// Next char must be alphanumeric and NOT `#` (real hashtag)
1019+
match chars.get(i + 1) {
1020+
Some(&next) if next.is_alphanumeric() => count += 1,
1021+
_ => {}
1022+
}
1023+
}
1024+
count
1025+
};
10051026
let has_question = text.contains('?');
10061027
let has_numbers = text.chars().any(|c| c.is_ascii_digit());
10071028
let starts_with_i = text.starts_with("I ") || text.starts_with("I'");
@@ -1769,4 +1790,29 @@ mod tests {
17691790
// Sanity: at least one issue fires
17701791
assert!(!result.issues.is_empty());
17711792
}
1793+
1794+
#[test]
1795+
fn hashtag_count_ignores_markdown_headers() {
1796+
// v1.7.1 fix: long-form Article bodies surfaced by FxTwitter use
1797+
// markdown header prefixes (## / ### ) for header-two/three blocks.
1798+
// The hashtag check must NOT count these as real X hashtags.
1799+
let text = "## Introduction\n\nSome words here.\n\n### Subsection\n\nMore content.";
1800+
let result = analyze(text, &default_ctx());
1801+
assert!(
1802+
!result.issues.iter().any(|i| i.code == "excessive_hashtags"),
1803+
"markdown headers should not trigger excessive_hashtags; got: {:?}",
1804+
result.issues.iter().map(|i| &i.code).collect::<Vec<_>>()
1805+
);
1806+
}
1807+
1808+
#[test]
1809+
fn hashtag_count_still_catches_real_hashtags() {
1810+
let text = "Loving #rust #programming #cli #dev — all the fun";
1811+
let result = analyze(text, &default_ctx());
1812+
assert!(
1813+
result.issues.iter().any(|i| i.code == "excessive_hashtags"),
1814+
"real hashtags should still trigger; got: {:?}",
1815+
result.issues.iter().map(|i| &i.code).collect::<Vec<_>>()
1816+
);
1817+
}
17721818
}

0 commit comments

Comments
 (0)