dexterm-cli 0.1.4 → 0.1.6
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/dist/packages/agents/src/index.js +2 -0
- package/dist/packages/agents/src/spectator.js +54 -0
- package/dist/packages/agents/src/story.js +523 -0
- package/dist/packages/agents/src/tiers.js +1 -0
- package/dist/packages/agents/src/tools.js +1 -0
- package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +1 -1
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +15 -8
- package/dist/packages/cli/src/agent-cmd.js +6 -4
- package/dist/packages/cli/src/agent-paper.js +60 -0
- package/dist/packages/cli/src/agent-spectator.js +17 -1
- package/dist/packages/cli/src/analyst/backend.js +206 -0
- package/dist/packages/cli/src/analyst/codex.js +301 -0
- package/dist/packages/cli/src/analyst/larp-gauge.js +60 -0
- package/dist/packages/cli/src/analyst/session.js +21 -3
- package/dist/packages/cli/src/app-help-gen.js +21 -12
- package/dist/packages/cli/src/backtest-cmd.js +13 -2
- package/dist/packages/cli/src/display.js +55 -9
- package/dist/packages/cli/src/doctor.js +58 -2
- package/dist/packages/cli/src/live/dev-history.js +115 -0
- package/dist/packages/cli/src/live/index.js +5 -1
- package/dist/packages/cli/src/live/newpairs.js +12 -0
- package/dist/packages/cli/src/live/screens/newpairs-live.js +1 -1
- package/dist/packages/cli/src/live/screens/token-live.js +51 -11
- package/dist/packages/cli/src/live/screens/watchlist-live.js +1 -1
- package/dist/packages/cli/src/live/token.js +1 -0
- package/dist/packages/cli/src/live/trade.js +22 -0
- package/dist/packages/cli/src/main.js +267 -19
- package/dist/packages/cli/src/mcp-follow.js +137 -10
- package/dist/packages/cli/src/plan-cmd.js +15 -5
- package/dist/packages/cli/src/popup.js +17 -1
- package/dist/packages/cli/src/record-cmd.js +2 -0
- package/dist/packages/cli/src/rules.js +25 -2
- package/dist/packages/cli/src/scan/ui.js +7 -1
- package/dist/packages/cli/src/screens/agent-hud.js +741 -0
- package/dist/packages/cli/src/screens/analyst-session.js +9 -3
- package/dist/packages/cli/src/screens/analyst.js +4 -3
- package/dist/packages/cli/src/screens/brain.js +608 -0
- package/dist/packages/cli/src/screens/newpairs.js +1 -1
- package/dist/packages/cli/src/screens/pnl-chip.js +143 -0
- package/dist/packages/cli/src/screens/screener.js +1 -0
- package/dist/packages/cli/src/screens/token.js +16 -7
- package/dist/packages/cli/src/screens/trending.js +1 -1
- package/dist/packages/cli/src/state.js +69 -5
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/custom/src/rules.js +2 -2
- package/dist/packages/custom/src/schema.js +13 -3
- package/dist/packages/data/src/fixtures.js +28 -6
- package/dist/packages/data/src/index.js +5 -0
- package/dist/packages/data/src/larp/score.js +220 -0
- package/dist/packages/data/src/larp/x-account.js +316 -0
- package/dist/packages/data/src/sources/jupiter.js +1 -0
- package/dist/packages/data/src/trade/agent/paper-desk.js +401 -0
- package/dist/packages/data/src/trade/agent/rendezvous.js +161 -0
- package/dist/packages/data/src/trade/plans/engine.js +7 -0
- package/dist/packages/data/src/trade/plans/mandate.js +28 -15
- package/dist/packages/data/src/trade/plans/service.js +1 -1
- package/dist/packages/mcp/assets/kb/app-help/agent-paper-trade.md +38 -0
- package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/dev-history.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/larp-check.md +5 -1
- package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +4 -1
- package/dist/packages/mcp/assets/kb/app-help/screen-token.md +4 -0
- package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +2 -1
- package/dist/packages/mcp/assets/kb/app-help/use-codex-analyst.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-agents-agent-paper-trading.md +51 -0
- package/dist/packages/mcp/assets/kb/docs/docs-agents-directory-metadata-prepared-not-submitted.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-agents-install-in-one-command.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-analyst-if-claude-is-missing.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-analyst-starting-a-run.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-analyst-use-codex-instead-of-claude.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-macos-or-linux-npm.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-chart-y-axis.md +32 -0
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-environment-variables.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-price-display-mode.md +3 -3
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-rules-engine.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-the-analyst-s-launch.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +13 -3
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-running-the-tests.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +1 -2
- package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +60 -0
- package/dist/packages/mcp/assets/kb/docs/docs-larp-the-verdict.md +0 -2
- package/dist/packages/mcp/assets/kb/docs/docs-larp-what-it-looks-at.md +4 -1
- package/dist/packages/mcp/assets/kb/docs/docs-larp.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-letting-an-agent-paper-trade-experimental.md +32 -0
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-order-plans-and-standing-mandates.md +1 -9
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-paper-only-mandates.md +41 -0
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-what-the-live-path-keeps.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-market-cap-axis.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-what-it-shows.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-scan-the-read.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-screens-dev-history.md +47 -0
- package/dist/packages/mcp/assets/kb/docs/docs-screens-how-well-it-predicts-measured-held-out.md +46 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-follow-over-mcp-in-the-running-terminal.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-limits.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-agent-hud-spectator-v2.md +58 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-brain-view-and-the-pnl-chip.md +46 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-three-surfaces.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-spectator.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-troubleshooting-doctor-lines.md +2 -0
- package/dist/packages/mcp/src/index.js +5 -2
- package/dist/packages/mcp/src/paper-agent.js +93 -0
- package/dist/packages/mcp/src/server.js +354 -19
- package/dist/packages/mcp/src/spectator-sink.js +158 -4
- package/dist/packages/mcp/src/user-content.js +1 -0
- package/dist/packages/packs/src/index.js +1 -0
- package/dist/packages/packs/src/larp-score-backtest.js +119 -0
- package/dist/packages/popup/src/pages/chart-lib.js +36 -15
- package/dist/packages/popup/src/pages/chart.css +9 -0
- package/dist/packages/popup/src/pages/chart.html +4 -1
- package/dist/packages/popup/src/pages/chart.js +34 -5
- package/dist/packages/popup/src/pages/spectator.css +45 -0
- package/dist/packages/popup/src/pages/spectator.html +1 -0
- package/dist/packages/popup/src/pages/spectator.js +69 -0
- package/dist/packages/popup/src/protocol.js +2 -0
- package/dist/packages/popup/src/server.js +5 -1
- package/dist/packages/recorder/src/dev-history.js +133 -0
- package/dist/packages/recorder/src/index.js +1 -0
- package/dist/packages/recorder/src/recorder.js +23 -0
- package/dist/packages/recorder/src/schema.js +11 -0
- package/dist/packages/tui/src/canvas.js +7 -0
- package/dist/packages/tui/src/fmt.js +53 -0
- package/dist/packages/tui/src/keymap.js +4 -0
- package/dist/packages/tui/src/widgets.js +9 -4
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// LARP score (task-1471): a 0-100 summary of the LARP check's flags. Pure, offline, deterministic.
|
|
2
|
+
//
|
|
3
|
+
// What it is: the transparent SUM of named flags, each with its points and the evidence that raised it. Higher
|
|
4
|
+
// means more of the things that go with LARP coins showed up. It is evidence, not a verdict: every flag can be
|
|
5
|
+
// innocent on its own, and the score never says a coin is fake, safe or worth anything.
|
|
6
|
+
//
|
|
7
|
+
// Missing data never lowers the score and is never scored as clean. A flag whose evidence could not be read (page
|
|
8
|
+
// blocked, RDAP down, the X sources unavailable) goes to `missing` with the points it COULD have added, and the
|
|
9
|
+
// range `[score, score + those points]` (and its band range) widens instead.
|
|
10
|
+
//
|
|
11
|
+
// Where the points come from (LARP_SCORE_WEIGHTS[*].source):
|
|
12
|
+
// - `calibrated`: the link-shape flags, fitted on the TRAIN split of the task-1401 eval set (1054 coins, 72 h
|
|
13
|
+
// outcome labels; packages/packs/src/larp-score-backtest.ts). points = clamp(round(8 × ln odds ratio of a bad
|
|
14
|
+
// outcome), 0, 12), using the flag only when ≥ 20 train coins carry it. The cap keeps link shape alone in the
|
|
15
|
+
// `medium` band at most: the larp-check skill never calls a coin LARP from links alone.
|
|
16
|
+
// - `prior`: page, domain, GitHub and X-account flags. The eval set does not hold that evidence point-in-time (the
|
|
17
|
+
// pages are gone or changed, and a deleted X account after a coin dies would leak the outcome), so these are
|
|
18
|
+
// pre-registered weights written from the skill text before any data, and are labelled as such everywhere.
|
|
19
|
+
import { linkSignals } from "./link-baseline.js";
|
|
20
|
+
export const LARP_SCORE_MODEL = 'larp-score-v1';
|
|
21
|
+
/**
|
|
22
|
+
* The shipped weights. The `calibrated` rows are asserted equal to a fresh fit on the committed eval set
|
|
23
|
+
* (packages/packs/test/larp-score-1471.test.ts), so a re-tune cannot ship stale points.
|
|
24
|
+
*/
|
|
25
|
+
export const LARP_SCORE_WEIGHTS = {
|
|
26
|
+
// links (DexScreener / Jupiter advertised links): calibrated where the eval set had ≥ 20 train coins.
|
|
27
|
+
no_website: { group: 'links', label: 'no website advertised', points: 9, source: 'calibrated' },
|
|
28
|
+
no_x: { group: 'links', label: 'no X account advertised', points: 10, source: 'calibrated' },
|
|
29
|
+
x_not_account: { group: 'links', label: 'X link is a single post or a community, not an account', points: 5, source: 'calibrated' },
|
|
30
|
+
no_telegram: { group: 'links', label: 'no Telegram advertised', points: 12, source: 'calibrated' },
|
|
31
|
+
site_is_social: { group: 'links', label: 'the "website" is a social page', points: 1, source: 'calibrated' },
|
|
32
|
+
domain_no_name_match: { group: 'links', label: "the website's domain does not name the token", points: 0, source: 'calibrated' },
|
|
33
|
+
cheap_tld: { group: 'links', label: 'website on a throwaway-priced TLD', points: 0, source: 'calibrated' },
|
|
34
|
+
site_is_launchpad: { group: 'links', label: 'the "website" is a launchpad/explorer page', points: 4, source: 'prior' },
|
|
35
|
+
site_linktree: { group: 'links', label: 'the "website" is a link hub', points: 2, source: 'prior' },
|
|
36
|
+
site_free_host: { group: 'links', label: 'website on a free/site-builder host', points: 2, source: 'prior' },
|
|
37
|
+
links_mismatch: { group: 'links', label: 'sources advertise different sites or X accounts', points: 8, source: 'prior' },
|
|
38
|
+
// website (one SSRF-safe fetch, no JavaScript)
|
|
39
|
+
drainer_markers: { group: 'website', label: 'wallet-drainer markers on the page', points: 30, source: 'prior' },
|
|
40
|
+
default_page: { group: 'website', label: 'website is a default/placeholder page', points: 15, source: 'prior' },
|
|
41
|
+
wallet_connect: { group: 'website', label: 'website asks to connect a wallet', points: 8, source: 'prior' },
|
|
42
|
+
never_names_token: { group: 'website', label: 'website never names the token', points: 8, source: 'prior' },
|
|
43
|
+
// domain (RDAP)
|
|
44
|
+
domain_new: { group: 'domain', label: 'domain registered under 30 days ago', points: 8, source: 'prior' },
|
|
45
|
+
domain_privacy: { group: 'domain', label: 'domain behind registrant privacy', points: 2, source: 'prior' },
|
|
46
|
+
// GitHub (keyless API)
|
|
47
|
+
no_github: { group: 'github', label: 'no GitHub repo advertised', points: 2, source: 'prior' },
|
|
48
|
+
github_archived: { group: 'github', label: 'GitHub repo is archived', points: 4, source: 'prior' },
|
|
49
|
+
github_fork: { group: 'github', label: 'GitHub repo is a fork', points: 4, source: 'prior' },
|
|
50
|
+
github_stale: { group: 'github', label: 'GitHub repo not pushed in 180+ days', points: 4, source: 'prior' },
|
|
51
|
+
// X account (keyless lookup chain)
|
|
52
|
+
x_suspended: { group: 'x', label: 'X account is suspended', points: 20, source: 'prior' },
|
|
53
|
+
x_not_found: { group: 'x', label: 'X account does not exist', points: 15, source: 'prior' },
|
|
54
|
+
x_account_days_old: { group: 'x', label: 'X account under 7 days old', points: 12, source: 'prior' },
|
|
55
|
+
x_account_new: { group: 'x', label: 'X account under 30 days old', points: 6, source: 'prior' },
|
|
56
|
+
x_tiny_following: { group: 'x', label: 'X account has under 50 followers', points: 5, source: 'prior' },
|
|
57
|
+
x_no_posts: { group: 'x', label: 'X account has no posts in 30 days', points: 6, source: 'prior' },
|
|
58
|
+
x_handle_mismatch: { group: 'x', label: "the website's X link and the advertised X account differ", points: 10, source: 'prior' },
|
|
59
|
+
x_renamed: { group: 'x', label: 'the linked X post is now under another handle', points: 5, source: 'prior' },
|
|
60
|
+
x_no_site_link: { group: 'x', label: "the X account's bio/website does not link the coin's site", points: 3, source: 'prior' },
|
|
61
|
+
};
|
|
62
|
+
/** Band thresholds (pre-registered round numbers; their measured precision is in LARP_SCORE_MEASURED). */
|
|
63
|
+
export const LARP_SCORE_BANDS = { medium: 25, high: 50 };
|
|
64
|
+
export const bandOf = (score) => (score >= LARP_SCORE_BANDS.high ? 'high' : score >= LARP_SCORE_BANDS.medium ? 'medium' : 'low');
|
|
65
|
+
/**
|
|
66
|
+
* Held-out TEST-split numbers for the link-shape part of the score (the only part the eval set can replay),
|
|
67
|
+
* from packages/packs/eval/larp-score-backtest.json (`dexterm backtest --subject larp-score`). A test asserts these match the committed report.
|
|
68
|
+
*/
|
|
69
|
+
export const LARP_SCORE_MEASURED = {
|
|
70
|
+
model: LARP_SCORE_MODEL,
|
|
71
|
+
eval: 'task-1401 eval set (built 2026-10-16), 72h outcome labels, bad = rug or dead',
|
|
72
|
+
part: 'link-shape flags only (page, domain, GitHub and X-account flags cannot be replayed on history)',
|
|
73
|
+
test: { n: 328, bad: 61, auroc: 0.691, by_band: { low: { n: 218, bad: 27 }, medium: { n: 110, bad: 34 }, high: { n: 0, bad: 0 } } },
|
|
74
|
+
train: { n: 726, bad: 294, auroc: 0.727 },
|
|
75
|
+
};
|
|
76
|
+
const W = LARP_SCORE_WEIGHTS;
|
|
77
|
+
const LINK_FLAG = {
|
|
78
|
+
site_is_social: 'site_is_social', site_is_launchpad: 'site_is_launchpad', site_linktree: 'site_linktree', site_free_host: 'site_free_host',
|
|
79
|
+
site_no_name_match: 'domain_no_name_match', cheap_tld: 'cheap_tld', x_not_profile: 'x_not_account', no_x: 'no_x', no_tg: 'no_telegram',
|
|
80
|
+
};
|
|
81
|
+
/** The link-shape flags of a feature set (shared by the live score and the backtest, so both score the same thing). */
|
|
82
|
+
export function linkFlagIds(f) {
|
|
83
|
+
const ids = linkSignals(f).map((s) => LINK_FLAG[s]);
|
|
84
|
+
if (f.web === 'none')
|
|
85
|
+
ids.unshift('no_website');
|
|
86
|
+
return ids;
|
|
87
|
+
}
|
|
88
|
+
/** Sum the link-shape points only (what the backtest scores). */
|
|
89
|
+
export function linkScore(f, weights = {}) {
|
|
90
|
+
return Math.min(100, linkFlagIds(f).reduce((a, id) => a + (weights[id] ?? W[id].points), 0));
|
|
91
|
+
}
|
|
92
|
+
/** Compute the LARP score from the gathered evidence. Pure. */
|
|
93
|
+
export function larpScore(ev) {
|
|
94
|
+
const flags = [];
|
|
95
|
+
const missing = [];
|
|
96
|
+
// A flag fitted to 0 points (it did not go with bad outcomes) is not listed: it would add nothing but noise.
|
|
97
|
+
const add = (id, evidence) => { const w = W[id]; if (w.points > 0)
|
|
98
|
+
flags.push({ id, group: w.group, label: w.label, points: w.points, weight: w.source, evidence }); };
|
|
99
|
+
const miss = (id, group, what, maxPoints) => { if (maxPoints > 0)
|
|
100
|
+
missing.push({ id, group, what, maxPoints }); };
|
|
101
|
+
const pts = (...ids) => ids.reduce((a, id) => a + W[id].points, 0);
|
|
102
|
+
if (ev.mode === 'token' && ev.link) {
|
|
103
|
+
const why = {
|
|
104
|
+
no_website: 'DexScreener/Jupiter list no website', no_x: 'DexScreener/Jupiter list no X link', no_telegram: 'DexScreener/Jupiter list no Telegram link',
|
|
105
|
+
x_not_account: `the advertised X link is a ${ev.link.x === 'status' ? 'single post' : ev.link.x === 'community' ? 'community' : 'non-account page'}`,
|
|
106
|
+
site_is_social: 'the advertised website is a social-network page', site_is_launchpad: 'the advertised website is a launchpad/explorer page',
|
|
107
|
+
site_linktree: 'the advertised website is a link hub', site_free_host: 'the advertised website is on a free/site-builder host',
|
|
108
|
+
domain_no_name_match: 'neither the symbol nor the name appears in the domain', cheap_tld: 'the domain is on a throwaway-priced TLD',
|
|
109
|
+
};
|
|
110
|
+
for (const id of linkFlagIds(ev.link))
|
|
111
|
+
add(id, why[id] ?? W[id].label);
|
|
112
|
+
}
|
|
113
|
+
if (ev.mismatch.length)
|
|
114
|
+
add('links_mismatch', ev.mismatch.join('; '));
|
|
115
|
+
// website page
|
|
116
|
+
if (ev.website) {
|
|
117
|
+
const p = ev.page;
|
|
118
|
+
if (!p || !p.ok)
|
|
119
|
+
miss('website:*', 'website', `website page not read${p && !p.ok ? ` (${p.reason})` : ''}`, pts('drainer_markers', 'default_page', 'wallet_connect', 'never_names_token'));
|
|
120
|
+
else {
|
|
121
|
+
if (p.drainerFlags.length)
|
|
122
|
+
add('drainer_markers', `markers: ${p.drainerFlags.slice(0, 3).join(', ')}`);
|
|
123
|
+
if (p.defaultPage)
|
|
124
|
+
add('default_page', 'the page matches a default/placeholder template');
|
|
125
|
+
if (p.walletConnect)
|
|
126
|
+
add('wallet_connect', 'the page carries a wallet-connect prompt');
|
|
127
|
+
if (p.mentionsSymbol === false && p.mentionsName === false)
|
|
128
|
+
add('never_names_token', 'neither the symbol nor the name appears in the page text');
|
|
129
|
+
else if (p.mentionsSymbol === null && p.mentionsName === null)
|
|
130
|
+
miss('never_names_token', 'website', 'whether the page names the token (no symbol/name to look for)', W.never_names_token.points);
|
|
131
|
+
}
|
|
132
|
+
const d = ev.domain;
|
|
133
|
+
if (!d || !d.ok)
|
|
134
|
+
miss('domain:*', 'domain', `domain registration not read${d && !d.ok ? ` (${d.reason})` : ''}`, pts('domain_new', 'domain_privacy'));
|
|
135
|
+
else {
|
|
136
|
+
if (typeof d.ageDays === 'number' && d.ageDays < 30)
|
|
137
|
+
add('domain_new', `registered ${d.ageDays} days ago (RDAP)`);
|
|
138
|
+
else if (d.ageDays === null)
|
|
139
|
+
miss('domain_new', 'domain', 'domain registration date', W.domain_new.points);
|
|
140
|
+
if (d.privacy === true)
|
|
141
|
+
add('domain_privacy', 'the RDAP registrant contact is redacted');
|
|
142
|
+
else if (d.privacy === null)
|
|
143
|
+
miss('domain_privacy', 'domain', 'registrant privacy', W.domain_privacy.points);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// GitHub
|
|
147
|
+
const g = ev.github;
|
|
148
|
+
if (g === 'none') {
|
|
149
|
+
if (ev.mode === 'token')
|
|
150
|
+
add('no_github', 'no github.com repo link among the advertised links or on the fetched page');
|
|
151
|
+
}
|
|
152
|
+
else if (g && !g.ok)
|
|
153
|
+
miss('github:*', 'github', `GitHub repo not read (${g.reason})`, pts('github_archived', 'github_fork', 'github_stale'));
|
|
154
|
+
else if (g) {
|
|
155
|
+
if (g.archived)
|
|
156
|
+
add('github_archived', 'the GitHub API marks the repo archived');
|
|
157
|
+
if (g.fork)
|
|
158
|
+
add('github_fork', 'the GitHub API marks the repo a fork');
|
|
159
|
+
if (typeof g.daysSincePush === 'number' && g.daysSincePush > 180)
|
|
160
|
+
add('github_stale', `last push ${g.daysSincePush} days ago`);
|
|
161
|
+
}
|
|
162
|
+
// X account
|
|
163
|
+
const x = ev.x;
|
|
164
|
+
if (x && x.handle) {
|
|
165
|
+
const at = `@${x.screenName ?? x.handle}`;
|
|
166
|
+
if (x.status === 'suspended')
|
|
167
|
+
add('x_suspended', `${at}: an X lookup source reports the account suspended`);
|
|
168
|
+
else if (x.status === 'not_found')
|
|
169
|
+
add('x_not_found', `@${x.handle}: two independent sources (vxtwitter, X oEmbed) found no such account`);
|
|
170
|
+
else if (x.status === 'unavailable')
|
|
171
|
+
miss('x:*', 'x', `X account not read (${x.unavailable[0] ?? 'every source failed'})`, Math.max(pts('x_suspended'), pts('x_account_days_old', 'x_tiny_following', 'x_no_posts', 'x_no_site_link')));
|
|
172
|
+
else {
|
|
173
|
+
if (typeof x.ageDays === 'number' && x.ageDays < 7)
|
|
174
|
+
add('x_account_days_old', `${at} created ${x.createdAt?.slice(0, 10)} (${x.ageDays} days ago)`);
|
|
175
|
+
else if (typeof x.ageDays === 'number' && x.ageDays < 30)
|
|
176
|
+
add('x_account_new', `${at} created ${x.createdAt?.slice(0, 10)} (${x.ageDays} days ago)`);
|
|
177
|
+
else if (x.ageDays === null)
|
|
178
|
+
miss('x_account_days_old', 'x', 'X account created date', W.x_account_days_old.points);
|
|
179
|
+
if (typeof x.followers === 'number' && x.followers < 50)
|
|
180
|
+
add('x_tiny_following', `${at} has ${x.followers} followers${x.countsSource === 'vxtwitter' && x.countsAsOf ? ` (cached copy from ${x.countsAsOf.slice(0, 16).replace('T', ' ')} UTC)` : ''}`);
|
|
181
|
+
else if (x.followers === null)
|
|
182
|
+
miss('x_tiny_following', 'x', 'X follower count', W.x_tiny_following.points);
|
|
183
|
+
if (x.tweets === 0)
|
|
184
|
+
add('x_no_posts', `${at} has never posted`);
|
|
185
|
+
else if (typeof x.daysSinceLastPost === 'number' && x.daysSinceLastPost > 30)
|
|
186
|
+
add('x_no_posts', `${at} last posted ${x.lastPostAt?.slice(0, 10)} (${x.daysSinceLastPost} days ago)`);
|
|
187
|
+
else if (x.lastPostAt === null && x.tweets !== 0)
|
|
188
|
+
miss('x_no_posts', 'x', 'X last post date', W.x_no_posts.points);
|
|
189
|
+
if (x.linksSite === false)
|
|
190
|
+
add('x_no_site_link', `${at}'s bio and website field do not mention ${ev.website ? new URL(ev.website).hostname.replace(/^www\./, '') : 'the site'}`);
|
|
191
|
+
else if (x.linksSite === null && ev.website)
|
|
192
|
+
miss('x_no_site_link', 'x', "whether the X account links the coin's site", W.x_no_site_link.points);
|
|
193
|
+
}
|
|
194
|
+
if (x.renamed)
|
|
195
|
+
add('x_renamed', x.renamed.evidence);
|
|
196
|
+
}
|
|
197
|
+
if (ev.xAgreement?.siteAgrees === false)
|
|
198
|
+
add('x_handle_mismatch', ev.xAgreement.note ?? "the website's X link and the advertised account differ");
|
|
199
|
+
const GROUP_MAX = {
|
|
200
|
+
website: pts('drainer_markers', 'default_page', 'wallet_connect', 'never_names_token'),
|
|
201
|
+
domain: pts('domain_new', 'domain_privacy'),
|
|
202
|
+
github: pts('github_archived', 'github_fork', 'github_stale'),
|
|
203
|
+
x: Math.max(pts('x_suspended'), pts('x_account_days_old', 'x_tiny_following', 'x_no_posts', 'x_no_site_link')),
|
|
204
|
+
};
|
|
205
|
+
for (const n of ev.notChecked ?? [])
|
|
206
|
+
if (!missing.some((m) => m.group === n.group))
|
|
207
|
+
miss(`${n.group}:*`, n.group, n.why, GROUP_MAX[n.group]);
|
|
208
|
+
flags.sort((a, b) => b.points - a.points);
|
|
209
|
+
const score = Math.min(100, flags.reduce((a, f) => a + f.points, 0));
|
|
210
|
+
const hi = Math.min(100, score + missing.reduce((a, m) => a + m.maxPoints, 0));
|
|
211
|
+
const m = LARP_SCORE_MEASURED.test;
|
|
212
|
+
return {
|
|
213
|
+
model: LARP_SCORE_MODEL, score, band: bandOf(score),
|
|
214
|
+
range: { lo: score, hi, bandLo: bandOf(score), bandHi: bandOf(hi) },
|
|
215
|
+
thresholds: { ...LARP_SCORE_BANDS },
|
|
216
|
+
flags, top: flags.filter((f) => f.points > 0).slice(0, 3), missing,
|
|
217
|
+
measured: `link-shape part only, held-out test split (${m.n} coins, ${m.bad} went bad within 72 h): AUROC ${m.auroc.toFixed(2)}; ${m.by_band.low.bad}/${m.by_band.low.n} low-band and ${m.by_band.medium.bad}/${m.by_band.medium.n} medium-band coins went bad. Page, domain, GitHub and X points are pre-registered priors, not fitted.`,
|
|
218
|
+
caveat: 'a sum of named flags, evidence not a verdict: any flag can be innocent, a low score is not a safety check, and missing data widens the range instead of lowering the score',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// LARP detector — keyless X account lookup (task-1471). Read-only, no login, no cookies, no X API key.
|
|
2
|
+
//
|
|
3
|
+
// Why a fallback chain: measured 2026-10-19 (handoff evidence x-repro.md), FxTwitter answers `404 User not found`
|
|
4
|
+
// for accounts that exist on roughly one call in six, a missing handle gets a 302 to the project's GitHub page on
|
|
5
|
+
// some calls, and "suspended" is reported only some of the time. vxtwitter serves a cached copy (its own
|
|
6
|
+
// `fetched_on`, up to about a day old). X's own oEmbed endpoint says exists / missing and the canonical handle, and
|
|
7
|
+
// nothing else. The X syndication timeline (429 / empty) and the Nitter-class mirrors (offline, suspended, or a 200
|
|
8
|
+
// "offline" page) gave nothing usable and are not in the chain.
|
|
9
|
+
//
|
|
10
|
+
// So no single answer is a verdict: `active` needs one source returning the profile; `suspended` needs a source
|
|
11
|
+
// saying so; `not_found` needs TWO independent sources (vxtwitter AND oEmbed) saying missing; anything else is
|
|
12
|
+
// `unavailable`. Every field that could not be read is listed in `unavailable` and is never guessed.
|
|
13
|
+
//
|
|
14
|
+
// Politeness: one handle per user action, at most five calls, a per-host spacing limiter, a TTL cache, and a
|
|
15
|
+
// circuit breaker per source (the shared HealthRegistry: 3 failures or 2× 429 in a minute → degraded, 30 s..10 min
|
|
16
|
+
// backoff). The bio is untrusted text: the caller firewalls it before it reaches a model.
|
|
17
|
+
import { HealthRegistry } from "../health.js";
|
|
18
|
+
import { systemClock } from "../clock.js";
|
|
19
|
+
import { parseHttpUrl } from "./links.js";
|
|
20
|
+
const RESERVED = new Set(['i', 'home', 'search', 'intent', 'hashtag', 'share', 'explore', 'settings', 'messages', 'notifications', 'login', 'signup', 'tos', 'privacy', 'compose']);
|
|
21
|
+
const HANDLE = /^[A-Za-z0-9_]{1,15}$/;
|
|
22
|
+
const X_HOSTS = new Set(['x.com', 'twitter.com', 'mobile.twitter.com', 'mobile.x.com', 'fxtwitter.com', 'vxtwitter.com', 'fixupx.com', 'nitter.net']);
|
|
23
|
+
const SOURCE_ID = { fxtwitter: 'fxtwitter', vxtwitter: 'vxtwitter', 'x-oembed': 'x-oembed' };
|
|
24
|
+
const HOST = { fxtwitter: 'api.fxtwitter.com', vxtwitter: 'api.vxtwitter.com', 'x-oembed': 'publish.x.com' };
|
|
25
|
+
/** Parse an advertised X link into its kind, handle and status id. Pure. */
|
|
26
|
+
export function parseXLink(raw) {
|
|
27
|
+
const u = parseHttpUrl(raw);
|
|
28
|
+
if (!u)
|
|
29
|
+
return { kind: 'other', handle: null, statusId: null };
|
|
30
|
+
const host = u.hostname.toLowerCase().replace(/^www\./, '');
|
|
31
|
+
if (!X_HOSTS.has(host))
|
|
32
|
+
return { kind: 'other', handle: null, statusId: null };
|
|
33
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
34
|
+
if (parts[0]?.toLowerCase() === 'i' && parts[1]?.toLowerCase() === 'communities')
|
|
35
|
+
return { kind: 'community', handle: null, statusId: null };
|
|
36
|
+
const h = parts[0] ?? '';
|
|
37
|
+
if (!HANDLE.test(h) || RESERVED.has(h.toLowerCase()))
|
|
38
|
+
return { kind: 'other', handle: null, statusId: null };
|
|
39
|
+
if (parts[1]?.toLowerCase() === 'status' && /^\d{1,25}$/.test(parts[2] ?? ''))
|
|
40
|
+
return { kind: 'status', handle: h, statusId: parts[2] };
|
|
41
|
+
return { kind: parts.length === 1 ? 'profile' : 'other', handle: h, statusId: null };
|
|
42
|
+
}
|
|
43
|
+
const json = (body) => {
|
|
44
|
+
try {
|
|
45
|
+
const v = JSON.parse(body);
|
|
46
|
+
return v && typeof v === 'object' ? v : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
53
|
+
const str = (v) => (typeof v === 'string' && v ? v : null);
|
|
54
|
+
const iso = (v) => { const t = typeof v === 'string' ? Date.parse(v) : NaN; return Number.isFinite(t) ? new Date(t).toISOString() : null; };
|
|
55
|
+
const days = (now, at) => (at ? Math.max(0, Math.floor((now - Date.parse(at)) / 86_400_000)) : null);
|
|
56
|
+
const bareHost = (raw) => { const u = raw ? parseHttpUrl(raw) : null; return u ? u.hostname.toLowerCase().replace(/^www\./, '') : null; };
|
|
57
|
+
function mentionsToken(bio, ctx) {
|
|
58
|
+
const sym = (ctx.symbol ?? '').trim();
|
|
59
|
+
const name = (ctx.name ?? '').trim();
|
|
60
|
+
if (!sym && !name && !ctx.mint)
|
|
61
|
+
return null;
|
|
62
|
+
const b = bio.toLowerCase();
|
|
63
|
+
if (ctx.mint && bio.includes(ctx.mint))
|
|
64
|
+
return true;
|
|
65
|
+
if (sym.length >= 2 && new RegExp(`(^|[^a-z0-9])\\$?${sym.toLowerCase().replace(/[^a-z0-9]/g, '')}([^a-z0-9]|$)`).test(b))
|
|
66
|
+
return true;
|
|
67
|
+
if (name.length >= 3 && b.includes(name.toLowerCase()))
|
|
68
|
+
return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
/** The lookup service: chain + cache + spacing + circuit breakers. One instance per process. */
|
|
72
|
+
export class XAccountLookup {
|
|
73
|
+
fetchImpl;
|
|
74
|
+
clock;
|
|
75
|
+
health;
|
|
76
|
+
minIntervalMs;
|
|
77
|
+
ttlMs;
|
|
78
|
+
ttlUnavailableMs;
|
|
79
|
+
maxEntries;
|
|
80
|
+
cache = new Map();
|
|
81
|
+
lastCall = new Map();
|
|
82
|
+
constructor(opts) {
|
|
83
|
+
this.fetchImpl = opts.fetch;
|
|
84
|
+
this.clock = opts.clock ?? systemClock;
|
|
85
|
+
this.health = opts.health ?? new HealthRegistry({ clock: this.clock });
|
|
86
|
+
this.minIntervalMs = opts.minIntervalMs ?? 1000;
|
|
87
|
+
this.ttlMs = opts.ttlMs ?? 30 * 60_000;
|
|
88
|
+
this.ttlUnavailableMs = opts.ttlUnavailableMs ?? 10 * 60_000;
|
|
89
|
+
this.maxEntries = opts.maxEntries ?? 500;
|
|
90
|
+
}
|
|
91
|
+
/** Per-host spacing: calls to one host are serialized at least minIntervalMs apart. */
|
|
92
|
+
async spaced(host, fn) {
|
|
93
|
+
const prev = this.lastCall.get(host) ?? Promise.resolve();
|
|
94
|
+
let release;
|
|
95
|
+
const mine = new Promise((r) => { release = r; });
|
|
96
|
+
this.lastCall.set(host, prev.then(() => mine));
|
|
97
|
+
await prev;
|
|
98
|
+
try {
|
|
99
|
+
return await fn();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (this.minIntervalMs > 0)
|
|
103
|
+
void this.clock.sleep(this.minIntervalMs).then(release);
|
|
104
|
+
else
|
|
105
|
+
release();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async get(source, endpoint, url, trace) {
|
|
109
|
+
const sid = SOURCE_ID[source];
|
|
110
|
+
if (!this.health.isAvailable(sid)) {
|
|
111
|
+
trace.push({ source, endpoint, status: null, result: 'skipped: circuit open (source degraded)', ms: 0 });
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const t0 = this.clock.now();
|
|
115
|
+
try {
|
|
116
|
+
const r = await this.spaced(HOST[source], () => this.fetchImpl(url));
|
|
117
|
+
const ms = this.clock.now() - t0;
|
|
118
|
+
// 404 / 3xx are answers (a vote), not source failures; 429 and 5xx other than vxtwitter's suspended body are.
|
|
119
|
+
const failed = r.status === 0 || r.status === 429 || (r.status >= 500 && !/suspended/i.test(r.body));
|
|
120
|
+
this.health.record(sid, failed ? { ok: false, status: r.status, latencyMs: ms, error: `http ${r.status}` } : { ok: true, status: r.status, latencyMs: ms });
|
|
121
|
+
return { status: r.status, body: r.body, ms };
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
const ms = this.clock.now() - t0;
|
|
125
|
+
this.health.record(sid, { ok: false, latencyMs: ms, error: e.message });
|
|
126
|
+
trace.push({ source, endpoint, status: null, result: `error: ${e.message}`.slice(0, 120), ms });
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Look up the account behind an advertised X link. Never throws. */
|
|
131
|
+
async lookup(xUrl, ctx = {}) {
|
|
132
|
+
const link = parseXLink(xUrl);
|
|
133
|
+
const now = this.clock.now();
|
|
134
|
+
const key = `${link.handle?.toLowerCase() ?? xUrl}|${link.statusId ?? ''}|${(ctx.symbol ?? '').toLowerCase()}|${(ctx.name ?? '').toLowerCase()}|${ctx.mint ?? ''}|${bareHost(ctx.website) ?? ''}`;
|
|
135
|
+
const hit = this.cache.get(key);
|
|
136
|
+
if (hit && now - hit.at < (hit.report.status === 'unavailable' ? this.ttlUnavailableMs : this.ttlMs))
|
|
137
|
+
return { ...hit.report, cached: true };
|
|
138
|
+
const report = await this.uncached(xUrl, link, ctx);
|
|
139
|
+
this.cache.delete(key);
|
|
140
|
+
this.cache.set(key, { at: now, report });
|
|
141
|
+
while (this.cache.size > this.maxEntries)
|
|
142
|
+
this.cache.delete(this.cache.keys().next().value);
|
|
143
|
+
return report;
|
|
144
|
+
}
|
|
145
|
+
async uncached(url, link, ctx) {
|
|
146
|
+
const now = this.clock.now();
|
|
147
|
+
const trace = [];
|
|
148
|
+
const base = {
|
|
149
|
+
url, kind: link.kind, handle: link.handle, status: 'unavailable', screenName: null, id: null, renamed: null,
|
|
150
|
+
createdAt: null, ageDays: null, followers: null, following: null, tweets: null, lastPostAt: null, daysSinceLastPost: null,
|
|
151
|
+
bio: null, website: null, bioMentionsToken: null, linksSite: null, countsAsOf: null, countsSource: null,
|
|
152
|
+
unavailable: [], sources: trace, cached: false, fetchedAt: new Date(now).toISOString(),
|
|
153
|
+
};
|
|
154
|
+
if (!link.handle) {
|
|
155
|
+
base.unavailable = ['account (the X link is not an account or a post)'];
|
|
156
|
+
return base;
|
|
157
|
+
}
|
|
158
|
+
let h = link.handle;
|
|
159
|
+
// A status link: who wrote the post now. A different author means a renamed account or a borrowed post.
|
|
160
|
+
if (link.statusId) {
|
|
161
|
+
const r = await this.get('fxtwitter', 'status', `https://api.fxtwitter.com/${h}/status/${link.statusId}`, trace);
|
|
162
|
+
const j = r ? json(r.body) : null;
|
|
163
|
+
const author = str(j?.tweet?.author?.screen_name);
|
|
164
|
+
if (r)
|
|
165
|
+
trace.push({ source: 'fxtwitter', endpoint: 'status', status: r.status, result: author ? `post by @${author}` : `no post (${str(j?.message) ?? r.status})`, ms: r.ms });
|
|
166
|
+
if (author && author.toLowerCase() !== h.toLowerCase()) {
|
|
167
|
+
base.renamed = { from: h, to: author, evidence: `the linked post ${link.statusId} is now shown under @${author}` };
|
|
168
|
+
h = author;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
let profile = null;
|
|
172
|
+
let suspended = false;
|
|
173
|
+
let missingVotes = 0;
|
|
174
|
+
const missingBy = [];
|
|
175
|
+
// 1) FxTwitter profile: fresh counts. A 404 is one vote only (measured false 404s).
|
|
176
|
+
const fx = await this.get('fxtwitter', 'profile', `https://api.fxtwitter.com/${h}`, trace);
|
|
177
|
+
if (fx) {
|
|
178
|
+
const j = json(fx.body);
|
|
179
|
+
const u = j?.user;
|
|
180
|
+
if (fx.status === 200 && u) {
|
|
181
|
+
profile = { screenName: str(u.screen_name), id: str(u.id), createdAt: iso(u.joined), followers: num(u.followers), following: num(u.following), tweets: num(u.tweets), bio: typeof u.description === 'string' ? u.description : null, website: str(u.website?.url), asOf: new Date(now).toISOString() };
|
|
182
|
+
trace.push({ source: 'fxtwitter', endpoint: 'profile', status: 200, result: 'profile', ms: fx.ms });
|
|
183
|
+
}
|
|
184
|
+
else if (str(j?.reason) === 'suspended') {
|
|
185
|
+
suspended = true;
|
|
186
|
+
trace.push({ source: 'fxtwitter', endpoint: 'profile', status: fx.status, result: 'suspended', ms: fx.ms });
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
trace.push({ source: 'fxtwitter', endpoint: 'profile', status: fx.status, result: fx.status >= 300 && fx.status < 400 ? 'redirect (missing handle or error page)' : `no profile (${str(j?.message) ?? fx.status})`, ms: fx.ms });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// 2) vxtwitter: cached copy; says suspended / missing reliably in the probe.
|
|
193
|
+
if (!profile) {
|
|
194
|
+
const vx = await this.get('vxtwitter', 'profile', `https://api.vxtwitter.com/${h}`, trace);
|
|
195
|
+
if (vx) {
|
|
196
|
+
const j = json(vx.body);
|
|
197
|
+
if (vx.status === 200 && j && str(j.screen_name)) {
|
|
198
|
+
const fetchedOn = num(j.fetched_on);
|
|
199
|
+
profile = { screenName: str(j.screen_name), id: j.id === undefined || j.id === null ? null : String(j.id), createdAt: iso(j.created_at), followers: num(j.followers_count), following: num(j.following_count), tweets: num(j.tweet_count), bio: typeof j.description === 'string' ? j.description : null, website: null, asOf: fetchedOn ? new Date(fetchedOn * 1000).toISOString() : null };
|
|
200
|
+
trace.push({ source: 'vxtwitter', endpoint: 'profile', status: 200, result: 'profile (cached copy)', ms: vx.ms });
|
|
201
|
+
}
|
|
202
|
+
else if (/suspended/i.test(str(j?.error) ?? '')) {
|
|
203
|
+
suspended = true;
|
|
204
|
+
trace.push({ source: 'vxtwitter', endpoint: 'profile', status: vx.status, result: 'suspended', ms: vx.ms });
|
|
205
|
+
}
|
|
206
|
+
else if (vx.status === 404) {
|
|
207
|
+
missingVotes++;
|
|
208
|
+
missingBy.push('vxtwitter');
|
|
209
|
+
trace.push({ source: 'vxtwitter', endpoint: 'profile', status: 404, result: 'not found', ms: vx.ms });
|
|
210
|
+
}
|
|
211
|
+
else
|
|
212
|
+
trace.push({ source: 'vxtwitter', endpoint: 'profile', status: vx.status, result: 'no answer', ms: vx.ms });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// 3) X oEmbed: exists vs missing (200 for a suspended account too).
|
|
216
|
+
let oembedExists = null;
|
|
217
|
+
if (!profile && !suspended) {
|
|
218
|
+
const oe = await this.get('x-oembed', 'oembed', `https://publish.x.com/oembed?url=${encodeURIComponent(`https://x.com/${h}`)}`, trace);
|
|
219
|
+
if (oe) {
|
|
220
|
+
const j = json(oe.body);
|
|
221
|
+
if (oe.status === 200 && j) {
|
|
222
|
+
oembedExists = true;
|
|
223
|
+
trace.push({ source: 'x-oembed', endpoint: 'oembed', status: 200, result: 'exists', ms: oe.ms });
|
|
224
|
+
}
|
|
225
|
+
else if (oe.status === 404) {
|
|
226
|
+
oembedExists = false;
|
|
227
|
+
missingVotes++;
|
|
228
|
+
missingBy.push('x-oembed');
|
|
229
|
+
trace.push({ source: 'x-oembed', endpoint: 'oembed', status: 404, result: 'not found', ms: oe.ms });
|
|
230
|
+
}
|
|
231
|
+
else
|
|
232
|
+
trace.push({ source: 'x-oembed', endpoint: 'oembed', status: oe.status, result: 'no answer', ms: oe.ms });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// 4) Last post date (FxTwitter statuses), only for an account that exists.
|
|
236
|
+
let lastPostAt = null;
|
|
237
|
+
let postsRead = false;
|
|
238
|
+
if (profile) {
|
|
239
|
+
const st = await this.get('fxtwitter', 'statuses', `https://api.fxtwitter.com/2/profile/${h}/statuses`, trace);
|
|
240
|
+
const j = st ? json(st.body) : null;
|
|
241
|
+
const results = Array.isArray(j?.results) ? j.results : null;
|
|
242
|
+
if (st?.status === 200 && results) {
|
|
243
|
+
postsRead = true;
|
|
244
|
+
const times = results.map((r) => iso(r.created_at)).filter((t) => !!t).sort();
|
|
245
|
+
lastPostAt = times.at(-1) ?? null;
|
|
246
|
+
trace.push({ source: 'fxtwitter', endpoint: 'statuses', status: 200, result: `${results.length} recent posts`, ms: st.ms });
|
|
247
|
+
}
|
|
248
|
+
else if (st)
|
|
249
|
+
trace.push({ source: 'fxtwitter', endpoint: 'statuses', status: st.status, result: 'posts unavailable', ms: st.ms });
|
|
250
|
+
}
|
|
251
|
+
const out = { ...base };
|
|
252
|
+
if (profile) {
|
|
253
|
+
out.status = 'active';
|
|
254
|
+
Object.assign(out, {
|
|
255
|
+
screenName: profile.screenName, id: profile.id, createdAt: profile.createdAt, ageDays: days(now, profile.createdAt),
|
|
256
|
+
followers: profile.followers, following: profile.following, tweets: profile.tweets,
|
|
257
|
+
bio: profile.bio !== null ? profile.bio.slice(0, 280) : null, website: profile.website,
|
|
258
|
+
countsAsOf: profile.asOf, countsSource: trace.find((t) => t.endpoint === 'profile' && t.status === 200)?.source ?? null,
|
|
259
|
+
lastPostAt, daysSinceLastPost: days(now, lastPostAt),
|
|
260
|
+
});
|
|
261
|
+
// A posts list that came back empty for an account with zero tweets is "no posts", not missing.
|
|
262
|
+
if (postsRead && !lastPostAt && profile.tweets === 0)
|
|
263
|
+
out.daysSinceLastPost = null;
|
|
264
|
+
out.bioMentionsToken = profile.bio !== null ? mentionsToken(profile.bio, ctx) : null;
|
|
265
|
+
const site = bareHost(ctx.website);
|
|
266
|
+
if (site) {
|
|
267
|
+
const bioHit = (profile.bio ?? '').toLowerCase().includes(site);
|
|
268
|
+
const webHit = bareHost(profile.website) === site;
|
|
269
|
+
out.linksSite = bioHit || webHit ? true : (profile.website !== null || profile.bio !== null) ? false : null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else if (suspended)
|
|
273
|
+
out.status = 'suspended';
|
|
274
|
+
else if (missingVotes >= 2)
|
|
275
|
+
out.status = 'not_found';
|
|
276
|
+
else if (oembedExists)
|
|
277
|
+
out.status = 'unavailable'; // exists per X, but no source returned the profile
|
|
278
|
+
const miss = [];
|
|
279
|
+
if (out.status !== 'active') {
|
|
280
|
+
if (out.status === 'unavailable')
|
|
281
|
+
miss.push(`account status (${missingBy.length ? `only ${missingBy.join(' + ')} said missing; ` : ''}no source returned the profile)`);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
if (out.createdAt === null)
|
|
285
|
+
miss.push('account created date');
|
|
286
|
+
if (out.followers === null)
|
|
287
|
+
miss.push('follower count');
|
|
288
|
+
if (out.following === null)
|
|
289
|
+
miss.push('following count');
|
|
290
|
+
if (out.tweets === null)
|
|
291
|
+
miss.push('post count');
|
|
292
|
+
if (!postsRead)
|
|
293
|
+
miss.push('last post date');
|
|
294
|
+
if (out.bio === null)
|
|
295
|
+
miss.push('bio');
|
|
296
|
+
if (ctx.website && out.linksSite === null)
|
|
297
|
+
miss.push('site cross-link');
|
|
298
|
+
if (out.bioMentionsToken === null && (ctx.symbol || ctx.name))
|
|
299
|
+
miss.push('bio mentions the token');
|
|
300
|
+
}
|
|
301
|
+
out.unavailable = miss;
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Advertised handles per source, and whether they and the site's own X links agree. Pure. */
|
|
306
|
+
export function xHandleAgreement(advertised, siteLinks) {
|
|
307
|
+
const handles = advertised.map((l) => ({ h: parseXLink(l.url), source: l.source })).filter((x) => x.h.handle).map((x) => ({ handle: x.h.handle, source: x.source }));
|
|
308
|
+
const distinct = [...new Set(handles.map((x) => x.handle.toLowerCase()))];
|
|
309
|
+
const siteHandles = siteLinks === null ? null : [...new Set(siteLinks.map((u) => parseXLink(u)).filter((x) => x.kind === 'profile' && x.handle).map((x) => x.handle.toLowerCase()))];
|
|
310
|
+
const siteLinksAdvertised = siteHandles === null || !distinct.length ? null : siteHandles.length === 0 ? false : siteHandles.some((s) => distinct.includes(s));
|
|
311
|
+
const agree = distinct.length === 0 ? null : distinct.length === 1 && (siteHandles === null || siteHandles.length === 0 || siteHandles.includes(distinct[0]));
|
|
312
|
+
const siteAgrees = !distinct.length || !siteHandles?.length ? null : siteHandles.some((s) => distinct.includes(s));
|
|
313
|
+
const note = distinct.length > 1 ? `sources advertise different X accounts: ${distinct.map((d) => `@${d}`).join(', ')}`
|
|
314
|
+
: agree === false && siteHandles?.length ? `the website links @${siteHandles.join(', @')} but the token lists @${distinct[0]}` : null;
|
|
315
|
+
return { handles, agree, siteAgrees, siteHandles, siteLinksAdvertised, note };
|
|
316
|
+
}
|
|
@@ -106,6 +106,7 @@ export function normalizeJupToken(raw, fetchedAt) {
|
|
|
106
106
|
top10Pct: numOrNull(raw.audit?.topHoldersPercentage),
|
|
107
107
|
devPct: numOrNull(raw.audit?.devBalancePercentage),
|
|
108
108
|
devMints: numOrNull(raw.audit?.devMints),
|
|
109
|
+
devAddress: typeof raw.dev === 'string' && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(raw.dev) ? raw.dev : null,
|
|
109
110
|
lpLockedPct: null,
|
|
110
111
|
rugRisks: [],
|
|
111
112
|
rugScore: null,
|