Skip to content

Commit e230367

Browse files
gisk0chapati23
authored andcommitted
feat: link congress trade detail line to Capitol Trades
1 parent 4c2cc9b commit e230367

5 files changed

Lines changed: 207 additions & 18 deletions

File tree

src/channels/telegram.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ export const formatSection = (section: BriefingSection): string => {
117117
const timeParts = item.timePrefix
118118
? `${item.timePrefix} ${timeStr}`
119119
: timeStr;
120-
const calendarLink = `[${escapeMarkdown(timeParts)}](${escapeUrlForMarkdown(item.calendarUrl)})`;
120+
const safeCalendarUrl = isSafeLinkUrl(item.calendarUrl)
121+
? item.calendarUrl
122+
: undefined;
123+
const calendarLink = safeCalendarUrl
124+
? `[${escapeMarkdown(timeParts)}](${escapeUrlForMarkdown(safeCalendarUrl)})`
125+
: escapeMarkdown(timeParts);
121126
const formattedText = formatTextWithMonospace(text);
122127
line = `${indent}${bullet} ${calendarLink} ${formattedText}`;
123128

@@ -135,12 +140,14 @@ export const formatSection = (section: BriefingSection): string => {
135140

136141
const label = labelledMatch?.[1];
137142
const value = labelledMatch?.[2];
143+
const safeItemUrl =
144+
item.url && isSafeLinkUrl(item.url) ? item.url : undefined;
138145

139-
if (label && value && item.url) {
146+
if (label && value && safeItemUrl) {
140147
const escapedLabel = escapeMarkdown(`${label}: `);
141148
const formattedValue = `\`${escapeMarkdownInCode(value)}\``;
142149
const sentimentPrefix = sentiment ? `${sentiment} ` : "";
143-
line = `${indent}${bullet} ${sentimentPrefix}${timePrefix}${escapedLabel}[${formattedValue}](${escapeUrlForMarkdown(item.url)})`;
150+
line = `${indent}${bullet} ${sentimentPrefix}${timePrefix}${escapedLabel}[${formattedValue}](${escapeUrlForMarkdown(safeItemUrl)})`;
144151
// Sentiment is already included as a prefix, don't add it again
145152
} else {
146153
// Regular items: link the whole text unless a specific substring is requested
@@ -149,10 +156,10 @@ export const formatSection = (section: BriefingSection): string => {
149156
const prefix = sentiment ? `${sentiment} ` : "";
150157
line = `${indent}${bullet} ${prefix}${monoText}`;
151158
} else {
152-
const formattedText = item.url
159+
const formattedText = safeItemUrl
153160
? item.linkText
154-
? formatTextWithLinkedSubstring(text, item.linkText, item.url)
155-
: `[${formatTextWithMonospace(text)}](${escapeUrlForMarkdown(item.url)})`
161+
? formatTextWithLinkedSubstring(text, item.linkText, safeItemUrl)
162+
: `[${formatTextWithMonospace(text)}](${escapeUrlForMarkdown(safeItemUrl)})`
156163
: formatTextWithMonospace(text);
157164

158165
if (item.sentimentPrefix && sentiment) {
@@ -174,8 +181,15 @@ export const formatSection = (section: BriefingSection): string => {
174181
const indent = isSubItem ? " " : " ";
175182
// Handle multi-line details - each line gets its own italic formatting
176183
const detailLines = item.detail.split("\n");
184+
const safeDetailUrl =
185+
item.detailUrl && isSafeLinkUrl(item.detailUrl)
186+
? item.detailUrl
187+
: undefined;
177188
for (const detailLine of detailLines) {
178-
lines.push(`${indent}_${escapeMarkdown(detailLine)}_`);
189+
const formattedDetail = safeDetailUrl
190+
? `_[${escapeMarkdown(detailLine)}](${escapeUrlForMarkdown(safeDetailUrl)})_`
191+
: `_${escapeMarkdown(detailLine)}_`;
192+
lines.push(`${indent}${formattedDetail}`);
179193
}
180194
}
181195

@@ -230,7 +244,19 @@ export const escapeMarkdown = (text: string): string => {
230244
return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
231245
};
232246

233-
// Escape URLs for use inside MarkdownV2 links - only ) and \ need escaping
247+
export const isSafeLinkUrl = (url: string): boolean => {
248+
try {
249+
const parsed = new URL(url);
250+
return (
251+
(parsed.protocol === "https:" || parsed.protocol === "http:") &&
252+
parsed.hostname.length > 0
253+
);
254+
} catch {
255+
return false;
256+
}
257+
};
258+
259+
// Escape URLs for use inside MarkdownV2 links - only ) and \\ need escaping
234260
export const escapeUrlForMarkdown = (url: string): string => {
235261
return url.replace(/([)\\])/g, "\\$1");
236262
};

src/sources/congress-trades.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,24 @@ const cleanTicker = (ticker: string): string => {
316316
return ticker.split(":")[0]?.trim() ?? ticker;
317317
};
318318

319+
const sanitizeCapitolTradesUrl = (href: string): string => {
320+
if (!href) return "";
321+
322+
try {
323+
const parsed = new URL(href, "https://www.capitoltrades.com");
324+
const isAllowedHost =
325+
parsed.hostname === "www.capitoltrades.com" ||
326+
parsed.hostname === "capitoltrades.com";
327+
const isTradePath = parsed.pathname.startsWith("/trades/");
328+
329+
if (!isAllowedHost || !isTradePath) return "";
330+
331+
return parsed.toString();
332+
} catch {
333+
return "";
334+
}
335+
};
336+
319337
export const parseCapitolTradesHTML = (html: string): CongressTrade[] => {
320338
const $ = cheerio.load(html);
321339
const trades: CongressTrade[] = [];
@@ -380,11 +398,7 @@ export const parseCapitolTradesHTML = (html: string): CongressTrade[] => {
380398
$(row).find("a[href*='/trades/']").attr("href") ??
381399
politicianCell.find("a[href*='/trades/']").attr("href") ??
382400
"";
383-
const url = rowLink
384-
? rowLink.startsWith("http")
385-
? rowLink
386-
: `https://www.capitoltrades.com${rowLink}`
387-
: "";
401+
const url = sanitizeCapitolTradesUrl(rowLink);
388402

389403
const tradeDate = parseTradeDate(tradeDateText);
390404
if (!tradeDate) {
@@ -486,7 +500,13 @@ export const getTradingViewUrl = (ticker: string): string => {
486500

487501
export const formatTradeItem = (
488502
trade: CongressTrade,
489-
): { text: string; detail: string; url: string; linkText: string } => {
503+
): {
504+
text: string;
505+
detail: string;
506+
detailUrl: string;
507+
url: string;
508+
linkText: string;
509+
} => {
490510
const prefix = trade.hot ? "🔥 " : "";
491511
const action = trade.type === "buy" ? "purchased" : "sold";
492512
const text = `${prefix}${formatChamber(trade.chamber)} ${trade.politician} (${formatPartyState(trade)}) ${action} ${trade.ticker}`;
@@ -497,6 +517,7 @@ export const formatTradeItem = (
497517
return {
498518
text,
499519
detail,
520+
detailUrl: trade.url,
500521
url: getTradingViewUrl(trade.ticker),
501522
linkText: trade.ticker,
502523
};
@@ -551,6 +572,7 @@ export const deduplicateTrades = (
551572
const totalAmountLower = group.reduce((sum, t) => sum + t.amountLower, 0);
552573
const maxScore = Math.max(...group.map((t) => t.score));
553574
const relevantTrade = group.find((t) => t.committeeRelevance);
575+
const detailTrade = group.find((t) => t.url) ?? first;
554576
result.push({
555577
politician: first.politician,
556578
party: first.party,
@@ -563,7 +585,7 @@ export const deduplicateTrades = (
563585
maxScore,
564586
hot: maxScore >= 6,
565587
trades: group,
566-
url: first.url,
588+
url: detailTrade.url,
567589
committeeRelevance: relevantTrade?.committeeRelevance ?? null,
568590
});
569591
}
@@ -594,7 +616,13 @@ const formatCompactAmount = (amount: number): string => {
594616

595617
export const formatDeduplicatedItem = (
596618
entry: CongressTrade | GroupedTrade,
597-
): { text: string; detail: string; url: string; linkText: string } => {
619+
): {
620+
text: string;
621+
detail: string;
622+
detailUrl: string;
623+
url: string;
624+
linkText: string;
625+
} => {
598626
if (!("count" in entry)) return formatTradeItem(entry);
599627

600628
const prefix = entry.hot ? "🔥 " : "";
@@ -608,6 +636,7 @@ export const formatDeduplicatedItem = (
608636
return {
609637
text,
610638
detail,
639+
detailUrl: entry.url,
611640
url: getTradingViewUrl(entry.ticker),
612641
linkText: entry.ticker,
613642
};
@@ -743,8 +772,14 @@ export const congressTradesSource: DataSource = {
743772
title: "Congress Trades",
744773
icon: "🏛",
745774
items: deduplicated.map((entry) => {
746-
const { text, detail, url, linkText } = formatDeduplicatedItem(entry);
747-
return { text, detail, ...(url ? { url, linkText } : {}) };
775+
const { text, detail, detailUrl, url, linkText } =
776+
formatDeduplicatedItem(entry);
777+
return {
778+
text,
779+
detail,
780+
...(detailUrl ? { detailUrl } : {}),
781+
...(url ? { url, linkText } : {}),
782+
};
748783
}),
749784
};
750785
} catch (error) {

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface BriefingSection {
2323
export interface BriefingItem {
2424
readonly text: string;
2525
readonly detail?: string;
26+
readonly detailUrl?: string; // Optional link target for the detail line
2627
readonly time?: Date;
2728
readonly timePrefix?: string; // Short label shown before the time (e.g. "Wed")
2829
readonly url?: string;

tests/congress-trades.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,26 @@ describe("parseCapitolTradesHTML", () => {
210210
).toBe(true);
211211
});
212212

213+
it("rejects non-Capitol-Trades absolute URLs in scraped trade links", () => {
214+
const html = `<html><body><table>
215+
<tr><th>H</th></tr>
216+
<tr>
217+
<td><h2 class="politician-name"><a>Test Person</a></h2><div class="politician-info"><span class="q-field party">Democrat</span><span class="q-field chamber">House</span><span class="q-field us-state-compact">CA</span><a href="https://evil.example/trades/123">detail</a></div></td>
218+
<td><h3 class="issuer-name"><a>Acme</a></h3><span class="issuer-ticker">ACME:US</span></td>
219+
<td>Yesterday</td>
220+
<td>10 Feb2026</td>
221+
<td>5 days</td>
222+
<td>Self</td>
223+
<td>buy</td>
224+
<td>100K–250K</td>
225+
<td>$100</td>
226+
</tr>
227+
</table></body></html>`;
228+
const trades = parseCapitolTradesHTML(html);
229+
expect(trades.length).toBe(1);
230+
expect(defined(trades[0]).url).toBe("");
231+
});
232+
213233
it("extracts party", () => {
214234
const trades = parseCapitolTradesHTML(html);
215235
for (const trade of trades) {
@@ -224,6 +244,28 @@ describe("parseCapitolTradesHTML", () => {
224244
}
225245
});
226246

247+
it("keeps valid Capitol Trades absolute URLs", () => {
248+
const html = `<html><body><table>
249+
<tr><th>H</th></tr>
250+
<tr>
251+
<td><h2 class="politician-name"><a>Test Person</a></h2><div class="politician-info"><span class="q-field party">Democrat</span><span class="q-field chamber">House</span><span class="q-field us-state-compact">CA</span><a href="https://www.capitoltrades.com/trades/123">detail</a></div></td>
252+
<td><h3 class="issuer-name"><a>Acme</a></h3><span class="issuer-ticker">ACME:US</span></td>
253+
<td>Yesterday</td>
254+
<td>10 Feb2026</td>
255+
<td>5 days</td>
256+
<td>Self</td>
257+
<td>buy</td>
258+
<td>100K–250K</td>
259+
<td>$100</td>
260+
</tr>
261+
</table></body></html>`;
262+
const trades = parseCapitolTradesHTML(html);
263+
expect(trades.length).toBe(1);
264+
expect(defined(trades[0]).url).toBe(
265+
"https://www.capitoltrades.com/trades/123",
266+
);
267+
});
268+
227269
it("extracts trade type", () => {
228270
const trades = parseCapitolTradesHTML(html);
229271
for (const trade of trades) {
@@ -548,6 +590,30 @@ describe("deduplicateTrades", () => {
548590
expect(url).toBe("https://www.tradingview.com/symbols/NVDA/");
549591
expect(linkText).toBe("NVDA");
550592
});
593+
594+
it("keeps Capitol Trades detailUrl for grouped entries", () => {
595+
const trades = [
596+
makeTrade({ url: "https://www.capitoltrades.com/trades/123" }),
597+
makeTrade({ amountLower: 500_000, score: 10 }),
598+
];
599+
const result = deduplicateTrades(trades);
600+
const { detailUrl } = formatDeduplicatedItem(defined(result[0]));
601+
expect(detailUrl).toBe("https://www.capitoltrades.com/trades/123");
602+
});
603+
604+
it("keeps Capitol Trades detailUrl when only a later grouped trade has it", () => {
605+
const trades = [
606+
makeTrade({ url: "" }),
607+
makeTrade({
608+
amountLower: 500_000,
609+
score: 10,
610+
url: "https://www.capitoltrades.com/trades/456",
611+
}),
612+
];
613+
const result = deduplicateTrades(trades);
614+
const { detailUrl } = formatDeduplicatedItem(defined(result[0]));
615+
expect(detailUrl).toBe("https://www.capitoltrades.com/trades/456");
616+
});
551617
});
552618

553619
// ============================================================================
@@ -632,6 +698,13 @@ describe("formatTradeItem", () => {
632698
expect(url).toBe("https://www.tradingview.com/symbols/NVDA/");
633699
expect(linkText).toBe("NVDA");
634700
});
701+
702+
it("returns Capitol Trades detailUrl for the second line", () => {
703+
const { detailUrl } = formatTradeItem(
704+
makeTrade({ url: "https://www.capitoltrades.com/trades/123" }),
705+
);
706+
expect(detailUrl).toBe("https://www.capitoltrades.com/trades/123");
707+
});
635708
});
636709

637710
// ============================================================================

tests/telegram.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
formatTextWithMonospace,
1414
formatTime,
1515
getSentimentEmoji,
16+
isSafeLinkUrl,
1617
} from "../src/channels/telegram";
1718
import type { Briefing, BriefingSection } from "../src/types";
1819

@@ -86,6 +87,24 @@ describe("escapeMarkdown", () => {
8687
// escapeUrlForMarkdown
8788
// ============================================================================
8889

90+
describe("isSafeLinkUrl", () => {
91+
it("allows https URLs", () => {
92+
expect(isSafeLinkUrl("https://example.com/path")).toBe(true);
93+
});
94+
95+
it("rejects javascript URLs", () => {
96+
expect(isSafeLinkUrl("javascript:alert(1)")).toBe(false);
97+
});
98+
99+
it("rejects invalid URLs", () => {
100+
expect(isSafeLinkUrl("not a url")).toBe(false);
101+
});
102+
});
103+
104+
// ============================================================================
105+
// escapeUrlForMarkdown
106+
// ============================================================================
107+
89108
describe("escapeUrlForMarkdown", () => {
90109
it("escapes closing parenthesis", () => {
91110
expect(escapeUrlForMarkdown("https://example.com/path)")).toBe(
@@ -402,6 +421,41 @@ describe("formatSection", () => {
402421
expect(result).toContain("_Detail line_");
403422
});
404423

424+
it("formats linked detail line", () => {
425+
const section: BriefingSection = {
426+
title: "Test",
427+
icon: "📊",
428+
items: [
429+
{
430+
text: "Item",
431+
detail: "$500K – $1M · traded Mar 18 · filed Apr 7",
432+
detailUrl: "https://www.capitoltrades.com/trades/123",
433+
},
434+
],
435+
};
436+
const result = formatSection(section);
437+
expect(result).toContain(
438+
"_[$500K – $1M · traded Mar 18 · filed Apr 7](https://www.capitoltrades.com/trades/123)_",
439+
);
440+
});
441+
442+
it("does not link unsafe detail URLs", () => {
443+
const section: BriefingSection = {
444+
title: "Test",
445+
icon: "📊",
446+
items: [
447+
{
448+
text: "Item",
449+
detail: "$500K – $1M · traded Mar 18 · filed Apr 7",
450+
detailUrl: "javascript:alert(1)",
451+
},
452+
],
453+
};
454+
const result = formatSection(section);
455+
expect(result).toContain("_$500K – $1M · traded Mar 18 · filed Apr 7_");
456+
expect(result).not.toContain("javascript:alert(1)");
457+
});
458+
405459
it("formats item with multi-line detail", () => {
406460
const section: BriefingSection = {
407461
title: "Test",

0 commit comments

Comments
 (0)