@rdcahalane/edgar-client 0.1.0
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/README.md +35 -0
- package/package.json +23 -0
- package/src/cik.ts +59 -0
- package/src/efts.ts +98 -0
- package/src/filing8k.ts +140 -0
- package/src/form4.ts +143 -0
- package/src/index.ts +36 -0
- package/src/xbrl.ts +99 -0
- package/tsconfig.json +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @rdcahalane/edgar-client
|
|
2
|
+
|
|
3
|
+
Unified client for public SEC EDGAR data.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- ticker-to-CIK lookup
|
|
8
|
+
- full-text filing search
|
|
9
|
+
- XBRL financial series access
|
|
10
|
+
- Form 4 insider transaction parsing
|
|
11
|
+
- 8-K event parsing
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @rdcahalane/edgar-client
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { lookupCIK, searchFilings, fetchFinancials } from "@rdcahalane/edgar-client";
|
|
23
|
+
|
|
24
|
+
const cik = await lookupCIK("AAPL");
|
|
25
|
+
const filings = await searchFilings({ ticker: "AAPL", form: "8-K" });
|
|
26
|
+
const revenue = await fetchFinancials("AAPL", "RevenueFromContractWithCustomerExcludingAssessedTax");
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Notes
|
|
30
|
+
|
|
31
|
+
- No API key required
|
|
32
|
+
- Includes courtesy delays for SEC-friendly usage
|
|
33
|
+
- You should set a real contact address in the User-Agent before production use
|
|
34
|
+
|
|
35
|
+
Good for finance tools, public-company research, and filing ingestion pipelines.
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rdcahalane/edgar-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Unified SEC EDGAR client: CIK lookup, EFTS search, XBRL financials, Form 4, 8-K parsing. Zero API key needed.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"typecheck": "tsc --noEmit --pretty false"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"typescript": "^5"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/cik.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CIK (Central Index Key) resolution.
|
|
3
|
+
*
|
|
4
|
+
* Maps ticker symbols to SEC CIK numbers and vice versa.
|
|
5
|
+
* Uses company_tickers.json — loaded once and cached.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { edgarFetch, RATE_LIMIT_MS } from "./index.js";
|
|
9
|
+
|
|
10
|
+
export interface CIKEntry {
|
|
11
|
+
cik: number;
|
|
12
|
+
ticker: string;
|
|
13
|
+
name: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let _tickerMap: Map<string, CIKEntry> | null = null;
|
|
17
|
+
let _cikMap: Map<number, CIKEntry> | null = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load the full SEC ticker→CIK mapping. Cached after first call.
|
|
21
|
+
*/
|
|
22
|
+
export async function loadTickerMap(): Promise<Map<string, CIKEntry>> {
|
|
23
|
+
if (_tickerMap) return _tickerMap;
|
|
24
|
+
|
|
25
|
+
const r = await edgarFetch("https://www.sec.gov/files/company_tickers.json");
|
|
26
|
+
if (!r.ok) throw new Error(`Failed to load company_tickers.json: ${r.status}`);
|
|
27
|
+
const data = await r.json() as Record<string, { cik_str: number; ticker: string; title: string }>;
|
|
28
|
+
|
|
29
|
+
_tickerMap = new Map();
|
|
30
|
+
_cikMap = new Map();
|
|
31
|
+
|
|
32
|
+
for (const entry of Object.values(data)) {
|
|
33
|
+
const e: CIKEntry = {
|
|
34
|
+
cik: entry.cik_str,
|
|
35
|
+
ticker: entry.ticker.toUpperCase(),
|
|
36
|
+
name: entry.title,
|
|
37
|
+
};
|
|
38
|
+
_tickerMap.set(e.ticker, e);
|
|
39
|
+
_cikMap.set(e.cik, e);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return _tickerMap;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve ticker → CIK number. Returns null if not found.
|
|
47
|
+
*/
|
|
48
|
+
export async function lookupCIK(ticker: string): Promise<number | null> {
|
|
49
|
+
const map = await loadTickerMap();
|
|
50
|
+
return map.get(ticker.toUpperCase())?.cik ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve CIK → ticker symbol. Returns null if not found.
|
|
55
|
+
*/
|
|
56
|
+
export async function lookupTicker(cik: number): Promise<string | null> {
|
|
57
|
+
if (!_cikMap) await loadTickerMap();
|
|
58
|
+
return _cikMap!.get(cik)?.ticker ?? null;
|
|
59
|
+
}
|
package/src/efts.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EDGAR Full-Text Search (EFTS)
|
|
3
|
+
*
|
|
4
|
+
* Search SEC filings by form type, date range, and content keywords.
|
|
5
|
+
* Used by 8-K, Form 4, 10-K, DEF 14A scrapers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { edgarFetch, RATE_LIMIT_MS } from "./index.js";
|
|
9
|
+
|
|
10
|
+
export interface FilingSearchResult {
|
|
11
|
+
accessionNo: string;
|
|
12
|
+
cik: number;
|
|
13
|
+
ticker?: string;
|
|
14
|
+
companyName: string;
|
|
15
|
+
formType: string;
|
|
16
|
+
filedAt: string;
|
|
17
|
+
documentUrl: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EFTS_BASE = "https://efts.sec.gov/LATEST/search-index";
|
|
22
|
+
|
|
23
|
+
interface EFTSParams {
|
|
24
|
+
/** Free-text query (e.g. '"Item 5.02"') */
|
|
25
|
+
query?: string;
|
|
26
|
+
/** Form types to search (e.g. "8-K", "10-K", "DEF 14A") */
|
|
27
|
+
forms?: string;
|
|
28
|
+
/** Start date (YYYY-MM-DD) */
|
|
29
|
+
startDate?: string;
|
|
30
|
+
/** End date (YYYY-MM-DD) */
|
|
31
|
+
endDate?: string;
|
|
32
|
+
/** Max results per page */
|
|
33
|
+
count?: number;
|
|
34
|
+
/** Start index for pagination */
|
|
35
|
+
from?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Search EDGAR filings via the full-text search API.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* // Find 8-K filings mentioning executive transitions in the last 90 days
|
|
43
|
+
* const results = await searchFilings({
|
|
44
|
+
* query: '"Item 5.02"',
|
|
45
|
+
* forms: '8-K',
|
|
46
|
+
* startDate: '2025-01-01',
|
|
47
|
+
* });
|
|
48
|
+
*/
|
|
49
|
+
export async function searchFilings(params: EFTSParams): Promise<FilingSearchResult[]> {
|
|
50
|
+
const searchParams = new URLSearchParams();
|
|
51
|
+
|
|
52
|
+
if (params.query) searchParams.set("q", params.query);
|
|
53
|
+
if (params.forms) searchParams.set("forms", params.forms);
|
|
54
|
+
if (params.startDate || params.endDate) {
|
|
55
|
+
searchParams.set("dateRange", "custom");
|
|
56
|
+
if (params.startDate) searchParams.set("startdt", params.startDate);
|
|
57
|
+
if (params.endDate) searchParams.set("enddt", params.endDate);
|
|
58
|
+
}
|
|
59
|
+
searchParams.set("from", String(params.from ?? 0));
|
|
60
|
+
searchParams.set("count", String(params.count ?? 40));
|
|
61
|
+
|
|
62
|
+
const url = `${EFTS_BASE}?${searchParams}`;
|
|
63
|
+
const r = await edgarFetch(url);
|
|
64
|
+
if (!r.ok) return [];
|
|
65
|
+
|
|
66
|
+
const data = await r.json() as {
|
|
67
|
+
hits: {
|
|
68
|
+
hits: Array<{
|
|
69
|
+
_source: Record<string, unknown> & {
|
|
70
|
+
display_names?: string[];
|
|
71
|
+
};
|
|
72
|
+
}>;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const hits = data?.hits?.hits ?? [];
|
|
77
|
+
const results: FilingSearchResult[] = [];
|
|
78
|
+
|
|
79
|
+
for (const hit of hits) {
|
|
80
|
+
const src = hit._source;
|
|
81
|
+
const accNo = String(src.file_num ?? src.accession_no ?? "").replace(/-/g, "");
|
|
82
|
+
const cik = Number(src.entity_id ?? src.cik ?? 0);
|
|
83
|
+
|
|
84
|
+
results.push({
|
|
85
|
+
accessionNo: accNo,
|
|
86
|
+
cik,
|
|
87
|
+
companyName: String(src.entity_name ?? src.display_names?.[0] ?? ""),
|
|
88
|
+
formType: String(src.form_type ?? src.file_type ?? ""),
|
|
89
|
+
filedAt: String(src.file_date ?? src.filed_at ?? ""),
|
|
90
|
+
documentUrl: src.file_url
|
|
91
|
+
? String(src.file_url)
|
|
92
|
+
: `https://www.sec.gov/Archives/edgar/data/${cik}/${accNo}/`,
|
|
93
|
+
description: String(src.display_description ?? ""),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return results;
|
|
98
|
+
}
|
package/src/filing8k.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 8-K Filing Parser
|
|
3
|
+
*
|
|
4
|
+
* Extracts structured events from SEC 8-K filings:
|
|
5
|
+
* - Item 5.02: Executive transitions (appointments/departures)
|
|
6
|
+
* - Item 2.04: Debt triggers
|
|
7
|
+
* - Item 1.01: M&A/material agreements
|
|
8
|
+
* - Item 1.02: Contract terminations
|
|
9
|
+
* - Item 2.05: Workforce reductions
|
|
10
|
+
* - Item 4.02: Financial restatements
|
|
11
|
+
* - Item 1.05: Cybersecurity incidents
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { searchFilings } from "./efts.js";
|
|
15
|
+
import { edgarFetch, RATE_LIMIT_MS } from "./index.js";
|
|
16
|
+
|
|
17
|
+
export interface Event8K {
|
|
18
|
+
ticker?: string;
|
|
19
|
+
companyName: string;
|
|
20
|
+
cik: number;
|
|
21
|
+
item: string;
|
|
22
|
+
itemDescription: string;
|
|
23
|
+
eventDate?: string;
|
|
24
|
+
filingDate: string;
|
|
25
|
+
accessionNo: string;
|
|
26
|
+
documentUrl: string;
|
|
27
|
+
/** Extracted people (for Item 5.02) */
|
|
28
|
+
people?: Array<{
|
|
29
|
+
name: string;
|
|
30
|
+
role_raw?: string;
|
|
31
|
+
direction: "appointment" | "departure" | "unknown";
|
|
32
|
+
}>;
|
|
33
|
+
/** Raw text snippet from the filing */
|
|
34
|
+
excerpt?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ITEM_DESCRIPTIONS: Record<string, string> = {
|
|
38
|
+
"1.01": "Entry into Material Agreement",
|
|
39
|
+
"1.02": "Termination of Material Agreement",
|
|
40
|
+
"1.05": "Material Cybersecurity Incident",
|
|
41
|
+
"2.04": "Triggering Events (Debt Acceleration)",
|
|
42
|
+
"2.05": "Costs of Workforce Reduction",
|
|
43
|
+
"4.02": "Non-Reliance on Previously Issued Financial Statements",
|
|
44
|
+
"5.02": "Departure/Election of Directors or Officers",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fetch 8-K events for a specific item type.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* // Get executive transitions from the last 90 days
|
|
52
|
+
* const events = await fetch8KEvents("5.02", { daysBack: 90 });
|
|
53
|
+
*/
|
|
54
|
+
export async function fetch8KEvents(
|
|
55
|
+
item: string,
|
|
56
|
+
opts: { daysBack?: number; limit?: number } = {}
|
|
57
|
+
): Promise<Event8K[]> {
|
|
58
|
+
const daysBack = opts.daysBack ?? 90;
|
|
59
|
+
const limit = opts.limit ?? 100;
|
|
60
|
+
|
|
61
|
+
const endDate = new Date();
|
|
62
|
+
const startDate = new Date(endDate.getTime() - daysBack * 86400000);
|
|
63
|
+
|
|
64
|
+
const filings = await searchFilings({
|
|
65
|
+
query: `"Item ${item}"`,
|
|
66
|
+
forms: "8-K",
|
|
67
|
+
startDate: startDate.toISOString().slice(0, 10),
|
|
68
|
+
endDate: endDate.toISOString().slice(0, 10),
|
|
69
|
+
count: Math.min(limit, 40),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const events: Event8K[] = [];
|
|
73
|
+
|
|
74
|
+
for (const filing of filings.slice(0, limit)) {
|
|
75
|
+
const event: Event8K = {
|
|
76
|
+
companyName: filing.companyName,
|
|
77
|
+
cik: filing.cik,
|
|
78
|
+
item,
|
|
79
|
+
itemDescription: ITEM_DESCRIPTIONS[item] ?? `Item ${item}`,
|
|
80
|
+
filingDate: filing.filedAt,
|
|
81
|
+
accessionNo: filing.accessionNo,
|
|
82
|
+
documentUrl: filing.documentUrl,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Extract ticker from company name if available (pattern: "COMPANY NAME (TICKER)")
|
|
86
|
+
const tickerMatch = filing.companyName.match(/\(([A-Z]{1,6})\)/);
|
|
87
|
+
if (tickerMatch) event.ticker = tickerMatch[1];
|
|
88
|
+
|
|
89
|
+
// For Item 5.02, try to extract people from the filing text
|
|
90
|
+
if (item === "5.02") {
|
|
91
|
+
try {
|
|
92
|
+
await new Promise(r => setTimeout(r, RATE_LIMIT_MS));
|
|
93
|
+
const docR = await edgarFetch(filing.documentUrl);
|
|
94
|
+
if (docR.ok) {
|
|
95
|
+
const text = await docR.text();
|
|
96
|
+
event.people = extractPeopleFrom502(text);
|
|
97
|
+
// Extract a short excerpt
|
|
98
|
+
const idx = text.toLowerCase().indexOf("item 5.02");
|
|
99
|
+
if (idx >= 0) {
|
|
100
|
+
event.excerpt = text.slice(idx, idx + 500).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} catch { /* skip extraction */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
events.push(event);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return events;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Extract people from Item 5.02 filing text.
|
|
114
|
+
*/
|
|
115
|
+
function extractPeopleFrom502(html: string): Event8K["people"] {
|
|
116
|
+
const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
|
|
117
|
+
const people: NonNullable<Event8K["people"]> = [];
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
|
|
120
|
+
// Pattern: "appointed X as Y" or "resignation of X" etc.
|
|
121
|
+
const patterns = [
|
|
122
|
+
{ re: /(?:appoint|elect|nam)\w*\s+([A-Z][a-z]+ (?:[A-Z]\. )?[A-Z][a-z]+(?:\s[A-Z][a-z]+)?)\s+(?:as|to)\s+([^.,]{3,50})/gi, dir: "appointment" as const },
|
|
123
|
+
{ re: /([A-Z][a-z]+ (?:[A-Z]\. )?[A-Z][a-z]+(?:\s[A-Z][a-z]+)?)\s+(?:has been|was)\s+(?:appoint|elect|nam)\w+\s+(?:as|to)\s+([^.,]{3,50})/gi, dir: "appointment" as const },
|
|
124
|
+
{ re: /(?:resign|depart|terminat|retir)\w*\s+(?:of\s+)?([A-Z][a-z]+ (?:[A-Z]\. )?[A-Z][a-z]+(?:\s[A-Z][a-z]+)?)/gi, dir: "departure" as const },
|
|
125
|
+
{ re: /([A-Z][a-z]+ (?:[A-Z]\. )?[A-Z][a-z]+(?:\s[A-Z][a-z]+)?)\s+(?:has\s+)?(?:resign|depart|retir)\w+/gi, dir: "departure" as const },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
for (const { re, dir } of patterns) {
|
|
129
|
+
for (const match of text.matchAll(re)) {
|
|
130
|
+
const name = match[1]?.trim();
|
|
131
|
+
const role = match[2]?.trim();
|
|
132
|
+
if (name && name.length > 4 && name.includes(" ") && !seen.has(name.toLowerCase())) {
|
|
133
|
+
seen.add(name.toLowerCase());
|
|
134
|
+
people.push({ name, role_raw: role, direction: dir });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return people;
|
|
140
|
+
}
|
package/src/form4.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form 4 Insider Transaction Parser
|
|
3
|
+
*
|
|
4
|
+
* Extracts officer/director names and trade details from SEC Form 4 filings.
|
|
5
|
+
* Useful for ownership tracking, officer lookup, and insider-trading analysis workflows.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { edgarFetch, RATE_LIMIT_MS } from "./index.js";
|
|
9
|
+
import { lookupCIK } from "./cik.js";
|
|
10
|
+
|
|
11
|
+
export interface Form4Person {
|
|
12
|
+
name: string;
|
|
13
|
+
title: string;
|
|
14
|
+
isOfficer: boolean;
|
|
15
|
+
isDirector: boolean;
|
|
16
|
+
is10PctOwner: boolean;
|
|
17
|
+
ticker: string;
|
|
18
|
+
cik: number;
|
|
19
|
+
/** Transactions (buys/sells) if available */
|
|
20
|
+
transactions?: Array<{
|
|
21
|
+
date: string;
|
|
22
|
+
code: string; // P=purchase, S=sale, A=award, etc.
|
|
23
|
+
shares: number;
|
|
24
|
+
pricePerShare: number;
|
|
25
|
+
sharesOwned: number;
|
|
26
|
+
}>;
|
|
27
|
+
accessionNo: string;
|
|
28
|
+
filingDate: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fetch Form 4 filers for a ticker.
|
|
33
|
+
*
|
|
34
|
+
* Returns officer/director names with their insider transactions.
|
|
35
|
+
*/
|
|
36
|
+
export async function fetchForm4People(
|
|
37
|
+
ticker: string,
|
|
38
|
+
opts: { limit?: number } = {}
|
|
39
|
+
): Promise<Form4Person[]> {
|
|
40
|
+
const cik = await lookupCIK(ticker);
|
|
41
|
+
if (!cik) return [];
|
|
42
|
+
|
|
43
|
+
const cikPadded = String(cik).padStart(10, "0");
|
|
44
|
+
const url = `https://data.sec.gov/submissions/CIK${cikPadded}.json`;
|
|
45
|
+
|
|
46
|
+
const r = await edgarFetch(url);
|
|
47
|
+
if (!r.ok) return [];
|
|
48
|
+
|
|
49
|
+
const data = await r.json() as {
|
|
50
|
+
filings: {
|
|
51
|
+
recent: {
|
|
52
|
+
accessionNumber: string[];
|
|
53
|
+
form: string[];
|
|
54
|
+
filingDate: string[];
|
|
55
|
+
primaryDocument: string[];
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const recent = data?.filings?.recent;
|
|
61
|
+
if (!recent) return [];
|
|
62
|
+
|
|
63
|
+
// Find Form 4 filings
|
|
64
|
+
const form4Indices: number[] = [];
|
|
65
|
+
for (let i = 0; i < recent.form.length; i++) {
|
|
66
|
+
if (recent.form[i] === "4" || recent.form[i] === "4/A") {
|
|
67
|
+
form4Indices.push(i);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const limit = opts.limit ?? 20;
|
|
72
|
+
const people: Form4Person[] = [];
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
|
|
75
|
+
for (const idx of form4Indices.slice(0, limit * 2)) {
|
|
76
|
+
const accNo = recent.accessionNumber[idx];
|
|
77
|
+
const filingDate = recent.filingDate[idx];
|
|
78
|
+
const docName = recent.primaryDocument[idx];
|
|
79
|
+
|
|
80
|
+
if (!accNo || !docName) continue;
|
|
81
|
+
|
|
82
|
+
// Fetch the Form 4 XML
|
|
83
|
+
const xmlUrl = `https://www.sec.gov/Archives/edgar/data/${cik}/${accNo.replace(/-/g, "")}/${docName}`;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await new Promise(r => setTimeout(r, RATE_LIMIT_MS));
|
|
87
|
+
const xmlR = await edgarFetch(xmlUrl, {
|
|
88
|
+
headers: { Accept: "application/xml,text/xml,text/html" } as Record<string, string>,
|
|
89
|
+
});
|
|
90
|
+
if (!xmlR.ok) continue;
|
|
91
|
+
|
|
92
|
+
const xmlText = await xmlR.text();
|
|
93
|
+
|
|
94
|
+
// Extract reporter name
|
|
95
|
+
const nameMatch = xmlText.match(/<rptOwnerName>([^<]+)/);
|
|
96
|
+
const titleMatch = xmlText.match(/<officerTitle>([^<]+)/);
|
|
97
|
+
const isOfficer = /<isOfficer>true/i.test(xmlText) || /<isOfficer>1/.test(xmlText);
|
|
98
|
+
const isDirector = /<isDirector>true/i.test(xmlText) || /<isDirector>1/.test(xmlText);
|
|
99
|
+
const is10Pct = /<isTenPercentOwner>true/i.test(xmlText) || /<isTenPercentOwner>1/.test(xmlText);
|
|
100
|
+
|
|
101
|
+
if (!nameMatch) continue;
|
|
102
|
+
|
|
103
|
+
const rawName = nameMatch[1].trim();
|
|
104
|
+
const key = rawName.toLowerCase();
|
|
105
|
+
if (seen.has(key)) continue;
|
|
106
|
+
seen.add(key);
|
|
107
|
+
|
|
108
|
+
// Extract transactions
|
|
109
|
+
const transactions: Form4Person["transactions"] = [];
|
|
110
|
+
const txMatches = xmlText.matchAll(
|
|
111
|
+
/<transactionDate>.*?<value>([^<]+).*?<transactionCoding>.*?<transactionCode>([^<]+).*?<transactionAmounts>.*?<transactionShares>.*?<value>([^<]+).*?<transactionPricePerShare>.*?<value>([^<]*)/gs
|
|
112
|
+
);
|
|
113
|
+
for (const tx of txMatches) {
|
|
114
|
+
transactions.push({
|
|
115
|
+
date: tx[1]?.trim() || filingDate,
|
|
116
|
+
code: tx[2]?.trim() || "?",
|
|
117
|
+
shares: parseFloat(tx[3]?.trim() || "0"),
|
|
118
|
+
pricePerShare: parseFloat(tx[4]?.trim() || "0"),
|
|
119
|
+
sharesOwned: 0,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
people.push({
|
|
124
|
+
name: rawName,
|
|
125
|
+
title: titleMatch?.[1]?.trim() || (isDirector ? "Director" : "Officer"),
|
|
126
|
+
isOfficer,
|
|
127
|
+
isDirector,
|
|
128
|
+
is10PctOwner: is10Pct,
|
|
129
|
+
ticker: ticker.toUpperCase(),
|
|
130
|
+
cik,
|
|
131
|
+
transactions: transactions.length ? transactions : undefined,
|
|
132
|
+
accessionNo: accNo,
|
|
133
|
+
filingDate,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (people.length >= limit) break;
|
|
137
|
+
} catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return people;
|
|
143
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edgar-client
|
|
3
|
+
*
|
|
4
|
+
* Unified SEC EDGAR client. Zero API key needed — all public data.
|
|
5
|
+
*
|
|
6
|
+
* Modules:
|
|
7
|
+
* - cik: Ticker → CIK resolution (cached)
|
|
8
|
+
* - efts: EDGAR Full-Text Search (8-K, 10-K, DEF 14A, etc.)
|
|
9
|
+
* - xbrl: XBRL financial time series (revenue, income, capex, etc.)
|
|
10
|
+
* - form4: Insider transaction parsing (officer/director names + trades)
|
|
11
|
+
* - filing8k: 8-K item extraction (5.02 exec transitions, 2.04 debt, 1.01 M&A)
|
|
12
|
+
*
|
|
13
|
+
* Rate limit: SEC allows 10 req/sec with proper User-Agent.
|
|
14
|
+
* All functions enforce a courtesy delay.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { lookupCIK, lookupTicker, loadTickerMap, type CIKEntry } from "./cik.js";
|
|
18
|
+
export { searchFilings, type FilingSearchResult } from "./efts.js";
|
|
19
|
+
export { fetchFinancials, type FinancialSeries } from "./xbrl.js";
|
|
20
|
+
export { fetchForm4People, type Form4Person } from "./form4.js";
|
|
21
|
+
export { fetch8KEvents, type Event8K } from "./filing8k.js";
|
|
22
|
+
|
|
23
|
+
/** Shared EDGAR headers — SEC requires contact info in User-Agent */
|
|
24
|
+
export const EDGAR_HEADERS = {
|
|
25
|
+
"User-Agent": "edgar-client/0.1.0 (opensource@example.com)",
|
|
26
|
+
"Accept": "application/json",
|
|
27
|
+
"Accept-Encoding": "gzip, deflate",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Courtesy delay between requests (ms) — SEC rate limit is 10/sec */
|
|
31
|
+
export const RATE_LIMIT_MS = 120;
|
|
32
|
+
|
|
33
|
+
export async function edgarFetch(url: string, init?: RequestInit): Promise<Response> {
|
|
34
|
+
const headers = { ...EDGAR_HEADERS, ...(init?.headers as Record<string, string> ?? {}) };
|
|
35
|
+
return fetch(url, { ...init, headers, signal: init?.signal ?? AbortSignal.timeout(30000) });
|
|
36
|
+
}
|
package/src/xbrl.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XBRL Financial Time Series
|
|
3
|
+
*
|
|
4
|
+
* Fetches quarterly financial data from SEC EDGAR's XBRL companyfacts API.
|
|
5
|
+
* No API key needed. Returns structured time series for fingerprinting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { edgarFetch, RATE_LIMIT_MS } from "./index.js";
|
|
9
|
+
import { lookupCIK } from "./cik.js";
|
|
10
|
+
|
|
11
|
+
export interface FinancialSeries {
|
|
12
|
+
ticker: string;
|
|
13
|
+
concept: string;
|
|
14
|
+
unit: string;
|
|
15
|
+
data: Array<{ date: string; value: number; form: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Core financial concepts to fetch */
|
|
19
|
+
const CORE_CONCEPTS = [
|
|
20
|
+
"us-gaap/Revenues",
|
|
21
|
+
"us-gaap/RevenueFromContractWithCustomerExcludingAssessedTax",
|
|
22
|
+
"us-gaap/NetIncomeLoss",
|
|
23
|
+
"us-gaap/OperatingIncomeLoss",
|
|
24
|
+
"us-gaap/GrossProfit",
|
|
25
|
+
"us-gaap/CostOfGoodsAndServicesSold",
|
|
26
|
+
"us-gaap/InventoriesNet",
|
|
27
|
+
"us-gaap/Assets",
|
|
28
|
+
"us-gaap/CapitalExpendituresIncurredButNotYetPaid",
|
|
29
|
+
"us-gaap/PaymentsToAcquirePropertyPlantAndEquipment",
|
|
30
|
+
"us-gaap/OperatingCashFlow",
|
|
31
|
+
"us-gaap/NetCashProvidedByOperatingActivities",
|
|
32
|
+
"us-gaap/EarningsPerShareDiluted",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fetch XBRL financial data for a ticker.
|
|
37
|
+
*
|
|
38
|
+
* Returns quarterly time series for core financial concepts.
|
|
39
|
+
* Falls back to alternate concept names (e.g. different revenue labels).
|
|
40
|
+
*/
|
|
41
|
+
export async function fetchFinancials(ticker: string): Promise<FinancialSeries[]> {
|
|
42
|
+
const cik = await lookupCIK(ticker);
|
|
43
|
+
if (!cik) return [];
|
|
44
|
+
|
|
45
|
+
const cikPadded = String(cik).padStart(10, "0");
|
|
46
|
+
const url = `https://data.sec.gov/api/xbrl/companyfacts/CIK${cikPadded}.json`;
|
|
47
|
+
|
|
48
|
+
const r = await edgarFetch(url);
|
|
49
|
+
if (!r.ok) return [];
|
|
50
|
+
|
|
51
|
+
const data = await r.json() as {
|
|
52
|
+
facts: Record<string, Record<string, {
|
|
53
|
+
units: Record<string, Array<{
|
|
54
|
+
end: string; val: number; form: string; fp: string; fy: number;
|
|
55
|
+
}>>;
|
|
56
|
+
}>>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const results: FinancialSeries[] = [];
|
|
60
|
+
|
|
61
|
+
for (const conceptPath of CORE_CONCEPTS) {
|
|
62
|
+
const [taxonomy, concept] = conceptPath.split("/");
|
|
63
|
+
const conceptData = data?.facts?.[taxonomy]?.[concept];
|
|
64
|
+
if (!conceptData) continue;
|
|
65
|
+
|
|
66
|
+
// Prefer USD units
|
|
67
|
+
const units = conceptData.units;
|
|
68
|
+
const unitKey = units["USD"] ? "USD" : units["USD/shares"] ? "USD/shares" : Object.keys(units)[0];
|
|
69
|
+
if (!unitKey || !units[unitKey]) continue;
|
|
70
|
+
|
|
71
|
+
// Filter to quarterly (10-Q) and annual (10-K) filings
|
|
72
|
+
const points = units[unitKey]
|
|
73
|
+
.filter(p => p.form === "10-Q" || p.form === "10-K")
|
|
74
|
+
.filter(p => p.end && !isNaN(p.val))
|
|
75
|
+
.map(p => ({ date: p.end, value: p.val, form: p.form }))
|
|
76
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
77
|
+
|
|
78
|
+
// Deduplicate by date (keep latest filing)
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const deduped = [];
|
|
81
|
+
for (let i = points.length - 1; i >= 0; i--) {
|
|
82
|
+
if (!seen.has(points[i].date)) {
|
|
83
|
+
seen.add(points[i].date);
|
|
84
|
+
deduped.unshift(points[i]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (deduped.length >= 4) {
|
|
89
|
+
results.push({
|
|
90
|
+
ticker: ticker.toUpperCase(),
|
|
91
|
+
concept,
|
|
92
|
+
unit: unitKey,
|
|
93
|
+
data: deduped,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return results;
|
|
99
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|