extract-cite 1.0.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.
Files changed (47) hide show
  1. package/README.md +56 -0
  2. package/dist/extract-cite.cjs.js +2 -0
  3. package/dist/extract-cite.cjs.js.map +1 -0
  4. package/dist/extract-cite.es.js +4 -0
  5. package/dist/extract-cite.es.js.map +1 -0
  6. package/dist/html-to-cite/extract-author.d.ts +11 -0
  7. package/dist/html-to-cite/extract-cite.d.ts +33 -0
  8. package/dist/html-to-cite/extract-date/date-extractors.d.ts +40 -0
  9. package/dist/html-to-cite/extract-date/date-validators.d.ts +15 -0
  10. package/dist/html-to-cite/extract-date/extract-date-quick.d.ts +8 -0
  11. package/dist/html-to-cite/extract-date/extract-date.d.ts +26 -0
  12. package/dist/html-to-cite/extract-source.d.ts +11 -0
  13. package/dist/html-to-cite/extract-title.d.ts +11 -0
  14. package/dist/html-to-cite/human-names-recognize.d.ts +16 -0
  15. package/dist/html-to-cite/metadata-to-cite.d.ts +16 -0
  16. package/dist/html-to-cite/url-to-domain.d.ts +20 -0
  17. package/dist/index.d.ts +14 -0
  18. package/dist/llm/call-llm.d.ts +33 -0
  19. package/dist/llm/extract-cite-llm.d.ts +19 -0
  20. package/dist/llm/format-citation.d.ts +22 -0
  21. package/dist/llm/index.d.ts +7 -0
  22. package/dist/llm/page-text.d.ts +21 -0
  23. package/dist/llm/parse-reply.d.ts +26 -0
  24. package/dist/llm/prompt.d.ts +14 -0
  25. package/dist/llm/types.d.ts +115 -0
  26. package/package.json +61 -0
  27. package/src/html-to-cite/extract-author.ts +125 -0
  28. package/src/html-to-cite/extract-cite.ts +97 -0
  29. package/src/html-to-cite/extract-date/date-extractors.ts +509 -0
  30. package/src/html-to-cite/extract-date/date-validators.ts +191 -0
  31. package/src/html-to-cite/extract-date/extract-date-quick.ts +184 -0
  32. package/src/html-to-cite/extract-date/extract-date.ts +1051 -0
  33. package/src/html-to-cite/extract-source.ts +35 -0
  34. package/src/html-to-cite/extract-title.ts +78 -0
  35. package/src/html-to-cite/human-names-92k.json +1 -0
  36. package/src/html-to-cite/human-names-recognize.ts +396 -0
  37. package/src/html-to-cite/metadata-to-cite.ts +77 -0
  38. package/src/html-to-cite/url-to-domain.ts +50 -0
  39. package/src/index.ts +14 -0
  40. package/src/llm/call-llm.ts +112 -0
  41. package/src/llm/extract-cite-llm.ts +100 -0
  42. package/src/llm/format-citation.ts +399 -0
  43. package/src/llm/index.ts +7 -0
  44. package/src/llm/page-text.ts +106 -0
  45. package/src/llm/parse-reply.ts +279 -0
  46. package/src/llm/prompt.ts +95 -0
  47. package/src/llm/types.ts +174 -0
@@ -0,0 +1,396 @@
1
+ /**
2
+ * @fileoverview Advanced human name recognition and normalization utility.
3
+ * Distinguishes between person names and organizations using a 92k-entry name database.
4
+ */
5
+ import dataHumanNames from "./human-names-92k.json";
6
+
7
+ // Common organization terms for detection - extended list from provided code
8
+ const TERMS_ORG =
9
+ "abc,ag,ap,academy,advisors,agency,airbnb,amazon,america,american,apple,associated,association,atlantic," +
10
+ "attorneys,authority,axel,bank,baptiste,bbc,bertelsmann,blackrock,bloomberg,bmw,boston,broadcasting,bureau," +
11
+ "business,buzzfeed,cambridge,capital,cbs,center,chase,chicago,china,church,citigroup,clinic,club,cnn,coca-cola," +
12
+ "college,commission,communications,cond\u00e9,consulting,corp,corps,costco,daily,department,der,deutsche,division,dow," +
13
+ "economist,enterprises,eu,european,fabrication,facebook,fargo,ferrari,financial,ford,forbes,fox,france,fund," +
14
+ "gannett,general,global,globe,gm,gmbh,goldman,google,group,guardian,harvard,hearst,herald,hill,holdings,home," +
15
+ "honda,hospital,huffington,ibm,inc,industries,institute,intel,international,investments,jazeera,japan,jones," +
16
+ "jpmorgan,lancet,laboratories,law,legal,linkedin,llc,los,ltd,manufacturing,macy's,mcdonald's,media,medical," +
17
+ "mercedes-benz,meta,microsoft,ministry,mit,morgan,mosque,msnbc,nast,national,nato,nbc,netflix,news,newsweek," +
18
+ "new,nike,nordstrom,npr,ny,organization,oxford,pa\u00eds,partners,pbs,pentagon,plc,politico,porsche,post,press," +
19
+ "productions,r&d,regiment,retail,reuters,research,rt,sachs,school,science,scientific,securities,services,silicon," +
20
+ "society,solutions,south,spacex,spiegel,springer,stanford,stanley,starbucks,straits,studios,sydney,synagogue," +
21
+ "systems,target,team,tech,techcrunch,temple,tesla,the,thomson,times,toronto,toyota,trust,twitter,uber,union," +
22
+ "united,university,usa,valley,vanguard,vice,volkswagen,volvo,vox,wall,walmart,welle,wells,who,white,wired," +
23
+ "worldwide,works,world,wsj,york,yorker";
24
+
25
+ // Qualification terms that might suggest the name belongs to a person rather than an organization
26
+ const TERMS_QUALIFICATIONS =
27
+ "is,senior,associate,professor,fellow,assistant,lecturer,ceo,staff,strategist,specialist,worked,directed," +
28
+ "correspondent,president,author,director,prof,asst,editor,analyst,degree,administrator,served,member," +
29
+ "institute,economist,reporter,head,heads,newspaper,deputy,advocate,colonel,officer,founder,founded,visiting," +
30
+ "journalist,former,retired,expert,executive,manager,doctoral,candidate,chief,contributor,student,blogger," +
31
+ "chair,chairman,major,general,ambassador,phd,secretary,physicist,engineer,research,office,school,department," +
32
+ "writer,teacher,advisor,award,center,commentator,rand,brookings,heritage,cato,un,aei,forbes,nyt,cbo";
33
+
34
+ /**
35
+ * Parses a full name into its component parts:
36
+ * Title, Firstname, Prefix, Middle, Lastname, Honorific, Alias
37
+ * https://en.wikipedia.org/wiki/List_of_family_name_affixes
38
+ * @param {string} input - The full name to parse.
39
+ * @returns {Object}
40
+ */
41
+ const extractHumanNameParts = (input) => {
42
+ // Initialize the result object
43
+ const result = {
44
+ prefix: "", //van der von de
45
+ firstname: "",
46
+ middle: "",
47
+ lastname: "",
48
+ honorific: "", //Jr Phd II
49
+ };
50
+
51
+ // Input validation
52
+ if (!input || typeof input !== "string") {
53
+ return result;
54
+ }
55
+
56
+ // Trim input and determine case fixing mode
57
+ input = input.trim();
58
+ const shouldFixCase =
59
+ input === input.toUpperCase() || input === input.toLowerCase();
60
+
61
+ // Define lists for parsing
62
+ const lists = {
63
+ honorific: ["esq", "esquire", "jr", "sr", "ii", "iii", "iv", "phd",
64
+ "md", "ms", "mrs", "mr", "miss", "dr"],
65
+ prefix: ["de", "van", "von", "der", "den", "vel", "le", "la", "da"],
66
+ title: ["mr", "mrs", "ms", "miss", "dr", "rev", "prof"],
67
+ };
68
+
69
+ // Extract alias
70
+ const aliasRegex =
71
+ /\s(['']([^'']+)['']|[""]([^""]+)[""]|\[([^\]]+)\]|\(([^\)]+)\)),?\s/g;
72
+ const aliasMatch = input.match(aliasRegex);
73
+ if (aliasMatch) {
74
+ input = input.replace(aliasRegex, " ");
75
+ }
76
+
77
+ // Split the name into parts
78
+ let parts = input.split(/\s+/);
79
+
80
+ // Extract honorific
81
+ const honorificIndex = parts.findIndex((part) =>
82
+ lists.honorific.includes(part.toLowerCase().replace(/\.$/, ""))
83
+ );
84
+ if (honorificIndex !== -1) {
85
+ result.honorific = parts.splice(honorificIndex).join(", ");
86
+ }
87
+
88
+ // Extract title
89
+ const titleIndex = parts.findIndex((part) =>
90
+ lists.title.includes(part.toLowerCase().replace(/\.$/, ""))
91
+ );
92
+ if (titleIndex !== -1) {
93
+ result.prefix = parts.splice(titleIndex, 1)[0];
94
+ }
95
+
96
+ // Join prefixes to following name parts
97
+ for (let i = parts.length - 2; i >= 0; i--) {
98
+ if (lists.prefix.includes(parts[i]?.toLowerCase())) {
99
+ parts[i] += " " + parts[i + 1];
100
+ parts.splice(i + 1, 1);
101
+ }
102
+ }
103
+
104
+ // Extract lastname name (if comma present)
105
+ const commaIndex = parts.findIndex((part) => part.endsWith(","));
106
+ if (commaIndex !== -1) {
107
+ result.lastname = parts
108
+ .splice(0, commaIndex + 1)
109
+ .join(" ")
110
+ .replace(/,$/, "");
111
+ } else {
112
+ result.lastname = parts.pop();
113
+ }
114
+
115
+ // Assign remaining parts to firstname and middle names
116
+ if (parts.length > 0) {
117
+ result.firstname = parts.shift();
118
+ if (parts.length > 0) {
119
+ result.middle = parts.join(" ");
120
+ }
121
+ }
122
+
123
+ // Fix case if needed
124
+ if (shouldFixCase) {
125
+ Object.keys(result).forEach((key) => {
126
+ if (result[key]) {
127
+ result[key] = result[key]
128
+ .split(" ")
129
+ .map(
130
+ (word) => word.charAt(0).toUpperCase() + word.slice(1)?.toLowerCase()
131
+ )
132
+ .join(" ");
133
+ }
134
+ });
135
+ }
136
+
137
+ return result;
138
+ };
139
+
140
+ export interface ExtractHumanNameOptions {
141
+ formatCiteShortenAuthor?: boolean;
142
+ maxAuthorsBeforeEtAl?: number;
143
+ }
144
+
145
+ /**
146
+ * Validates and formats author names properly handling multiple authors and multi-word names
147
+ *
148
+ * @param {string} author - The author name string(s) to be processed
149
+ * @param {ExtractHumanNameOptions} [options={}] - Configuration options
150
+ * @returns {object} Formatted author information for citation
151
+ */
152
+ export function extractHumanName(
153
+ author: string,
154
+ options: ExtractHumanNameOptions = {}
155
+ ) {
156
+ const { formatCiteShortenAuthor = false, maxAuthorsBeforeEtAl = 2 } = options;
157
+
158
+ if (!author || !author.split) {
159
+ return { author_cite: "", author_short: "", author_type: 4 };
160
+ }
161
+
162
+ // Clean up the input string
163
+ author = author.trim()
164
+ .replace(/^by:?\s*/i, "") // Remove "by:" prefix
165
+ .replace(/\s{2,}/g, " "); // Normalize spaces
166
+
167
+ // Split into multiple authors if present
168
+ const authorNames = splitMultipleAuthors(author);
169
+
170
+ if (authorNames.length === 0) {
171
+ return { author_cite: "", author_short: "", author_type: 4 };
172
+ }
173
+
174
+ // Process each author
175
+ const processedAuthors = authorNames.map(authorName => {
176
+ // Check if name is likely an organization
177
+ const isOrg = isOrganization(authorName);
178
+
179
+ // Extract name parts using the provided function
180
+ const nameParts = extractHumanNameParts(authorName);
181
+
182
+ return {
183
+ original: authorName,
184
+ nameParts,
185
+ isOrg
186
+ };
187
+ });
188
+
189
+ // Determine overall author type
190
+ let authorType = 4; // Default to unknown
191
+
192
+ if (processedAuthors.length === 1) {
193
+ authorType = processedAuthors[0].isOrg ? 3 : 0; // 3 = org, 0 = single
194
+ } else if (processedAuthors.length === 2) {
195
+ authorType = 1; // two-author
196
+ } else if (processedAuthors.length > 2) {
197
+ authorType = 2; // more-than-two
198
+ }
199
+
200
+ // Format authors for citation
201
+ const formattedAuthors = processedAuthors.map(author => {
202
+ if (author.isOrg) {
203
+ // Don't reverse organization names
204
+ const maxOrgNameLength = 60;
205
+ let orgName = author.original;
206
+ if (orgName.length > maxOrgNameLength) {
207
+ orgName = orgName.substring(0, orgName.slice(0, maxOrgNameLength).lastIndexOf(" "));
208
+ }
209
+ return {
210
+ cite: orgName,
211
+ short: orgName
212
+ };
213
+ } else {
214
+ // Format human name using name parts
215
+ const nameParts = author.nameParts;
216
+
217
+ // Handle empty or malformed name parts
218
+ if (!nameParts || !nameParts.lastname) {
219
+ return { cite: author.original, short: author.original };
220
+ }
221
+
222
+ let formattedFirstName = nameParts.firstname;
223
+
224
+ // Include middle name with first name if present
225
+ if (nameParts.middle) {
226
+ formattedFirstName += " " + nameParts.middle;
227
+ }
228
+
229
+ // Add prefix to last name if present
230
+ let lastName = nameParts.lastname;
231
+ if (nameParts.prefix) {
232
+ lastName = `${nameParts.prefix} ${lastName}`;
233
+ }
234
+
235
+ // Shorten first name if option is set
236
+ if (formatCiteShortenAuthor && formattedFirstName) {
237
+ formattedFirstName = formattedFirstName
238
+ .split(/\s+/)
239
+ .map(part => part[0] + ".")
240
+ .join(" ");
241
+ }
242
+
243
+ // Add honorific if present
244
+ let cite = `${lastName}, ${formattedFirstName}`.trim();
245
+ if (nameParts.honorific) {
246
+ cite += `, ${nameParts.honorific}`;
247
+ }
248
+
249
+ return {
250
+ cite: cite.replace(/,\s*$/, ""),
251
+ short: lastName
252
+ };
253
+ }
254
+ });
255
+
256
+ // Generate citation strings
257
+ let authorCite, authorShort;
258
+
259
+ if (authorType === 0 || authorType === 3) {
260
+ // Single author or organization
261
+ authorCite = formattedAuthors[0].cite;
262
+ authorShort = formattedAuthors[0].short;
263
+ } else if (authorType === 1) {
264
+ // Two authors
265
+ authorCite = `${formattedAuthors[0].cite} & ${formattedAuthors[1].cite}`;
266
+ authorShort = `${formattedAuthors[0].short} & ${formattedAuthors[1].short}`;
267
+ } else if (authorType === 2) {
268
+ // More than two authors
269
+ if (processedAuthors.length <= maxAuthorsBeforeEtAl) {
270
+ // List all authors with commas and "and" before the last one
271
+ const lastAuthor = formattedAuthors.pop();
272
+ authorCite = formattedAuthors.map(a => a.cite).join(", ");
273
+ if (lastAuthor) {
274
+ authorCite += ` & ${lastAuthor.cite}`;
275
+ }
276
+ authorShort = `${formattedAuthors[0].short} et al.`;
277
+ } else {
278
+ // Use et al. format
279
+ authorCite = `${formattedAuthors[0].cite} et al.`;
280
+ authorShort = `${formattedAuthors[0].short} et al.`;
281
+ }
282
+ } else {
283
+ // Unknown/error case
284
+ authorCite = author;
285
+ authorShort = author;
286
+ }
287
+
288
+ return {
289
+ author_cite: authorCite,
290
+ author_short: authorShort,
291
+ author_type: authorType
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Splits a string containing multiple authors into individual author names
297
+ *
298
+ * @param {string} authorString - String potentially containing multiple authors
299
+ * @returns {string[]} Array of individual author names
300
+ */
301
+ function splitMultipleAuthors(authorString) {
302
+ if (!authorString) return [];
303
+
304
+ // Remove "et al." since we're parsing actual authors
305
+ authorString = authorString.replace(/\s+et\s+al\.?/gi, "");
306
+
307
+ // Handle common formatting patterns for multiple authors
308
+
309
+ // Pattern 1: Last, First & Last, First
310
+ if (/\w+,\s*\w+\s*&\s*\w+,\s*\w+/.test(authorString)) {
311
+ return authorString.split(/\s*&\s*/);
312
+ }
313
+
314
+ // Pattern 2: Last, First, Last, First, and Last, First
315
+ if (/\w+,\s*\w+,\s*\w+,\s*\w+/.test(authorString)) {
316
+ // Replace the last comma+and with a standard separator
317
+ authorString = authorString.replace(/,\s*(and|&)\s*(?=[^,]*$)/, " & ");
318
+ return authorString.split(/\s*,\s*(?=[^,]*(?:,|$))/).map(s => s.trim());
319
+ }
320
+
321
+ // Pattern 3: First Last, First Last, and First Last
322
+ if (/\w+\s\w+,\s\w+\s\w+/.test(authorString)) {
323
+ // Replace the last comma+and with a standard separator
324
+ authorString = authorString.replace(/,\s*(and|&)\s*(?=[^,]*$)/, " & ");
325
+ return authorString.split(/\s*,\s*/).map(s => s.trim());
326
+ }
327
+
328
+ // Pattern 4: First Last and First Last
329
+ if (/\w+\s\w+\s(and|&)\s\w+\s\w+/.test(authorString)) {
330
+ return authorString.split(/\s+(and|&)\s+/).map(s => s.trim());
331
+ }
332
+
333
+ // Default pattern - try to split by various separators
334
+ // Replace common author separators with a standard one for easier processing
335
+ authorString = authorString
336
+ .replace(/\s+and\s+/gi, " & ")
337
+ .replace(/\s*[,;]\s*(?!(?:[^(]*\)))/g, " & "); // Replace commas/semicolons outside parentheses
338
+
339
+ // Split by the standard separator
340
+ return authorString.split(/\s*&\s*/).filter(author => author.trim().length > 0);
341
+ }
342
+
343
+ /**
344
+ * Determines if a name string represents an organization rather than a person
345
+ *
346
+ * @param {string} nameString - The name to analyze
347
+ * @returns {boolean} True if the name appears to be an organization
348
+ */
349
+ function isOrganization(nameString) {
350
+ if (!nameString) return false;
351
+
352
+ // Convert organization terms to array
353
+ const orgTerms = TERMS_ORG.split(",");
354
+ const qualTerms = TERMS_QUALIFICATIONS.split(",");
355
+
356
+ // Clean and normalize the name string
357
+ const nameLower = nameString.toLowerCase().replace(/[^\w\s]/g, " ");
358
+ const words = nameLower.split(/\s+/);
359
+
360
+ // Check for organization terms
361
+ for (const word of words) {
362
+ if (orgTerms.includes(word)) {
363
+ return true;
364
+ }
365
+ }
366
+
367
+ // Check for qualification terms that suggest it's a person
368
+ for (const word of words) {
369
+ if (qualTerms.includes(word)) {
370
+ return false;
371
+ }
372
+ }
373
+
374
+ // Look for name patterns that suggest it's a person
375
+ if (/,\s*\w+/.test(nameString)) { // Has comma format like "Smith, John"
376
+ return false;
377
+ }
378
+
379
+ // If there are more than 4 words and no commas, it's likely an organization
380
+ if (words.length > 4 && !nameString.includes(",")) {
381
+ return true;
382
+ }
383
+
384
+ // Check if the name contains any human name parts according to our database
385
+ let hasHumanNamePart = false;
386
+ for (const word of words) {
387
+ const nameTitle = word[0]?.toUpperCase() + word.slice(1)?.toLowerCase();
388
+ if (dataHumanNames[nameTitle] === 1 || dataHumanNames[nameTitle] === 2) {
389
+ hasHumanNamePart = true;
390
+ break;
391
+ }
392
+ }
393
+
394
+ // If no human name parts found and more than 2 words, likely an organization
395
+ return !hasHumanNamePart && words.length > 2;
396
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @fileoverview Extracts citation metadata (author, date, title, source) from
3
+ * a document's `<meta>` tags using a list of commonly used property/name attributes.
4
+ */
5
+
6
+ export interface CiteMetadata {
7
+ author?: string;
8
+ date?: string;
9
+ title?: string;
10
+ source?: string;
11
+ }
12
+
13
+ /**
14
+ * Extract cite info from common property names in webpage's metadata
15
+ * @param {Document} doc dom object of document
16
+ * @returns {CiteMetadata} author, date, title, source
17
+ */
18
+ export function extractCiteFromMetadata(doc: Document): CiteMetadata {
19
+ if (!doc) return null;
20
+
21
+ const commonCiteMetaTags = {
22
+ source: ["application-name", "og:site_name", "twitter:site", "dc.title"],
23
+ title: ["title", "og:title", "twitter:title", "parsely-title"],
24
+ author: [
25
+ "author",
26
+ "creator",
27
+ "og:creator",
28
+ "article:author",
29
+ "dc.creator",
30
+ "parsely-author",
31
+ ],
32
+ date: [
33
+ "article:published_time",
34
+ "article:modified_time",
35
+ "og:updated_time",
36
+ "dc.date",
37
+ "dc.date.issued",
38
+ "dc.date.created",
39
+ "dc:created",
40
+ "dcterms.date",
41
+ "datepublished",
42
+ "datemodified",
43
+ "updated_time",
44
+ "modified_time",
45
+ "published_time",
46
+ "release_date",
47
+ "date",
48
+ "parsely-pub-date",
49
+ "article:published",
50
+ "article:published_time",
51
+ "og:pubdate",
52
+ "pubdate",
53
+ "date",
54
+ "dateCreated",
55
+ "pdate",
56
+ "sailthru.date",
57
+ "dcterms.created",
58
+ ],
59
+ };
60
+
61
+ const result = {};
62
+
63
+ Array.from(doc.getElementsByTagName("meta")).forEach((metaElem) => {
64
+ const property =
65
+ metaElem.getAttribute("property") || metaElem.getAttribute("itemprop");
66
+ const name = metaElem.getAttribute("name");
67
+
68
+ for (const [key, attrs] of Object.entries(commonCiteMetaTags))
69
+ if (
70
+ metaElem.getAttribute("content") &&
71
+ (attrs.includes(property?.toLowerCase()) ||
72
+ attrs.includes(name?.toLowerCase()))
73
+ )
74
+ result[key] = metaElem.getAttribute("content");
75
+ });
76
+ return result;
77
+ }
@@ -0,0 +1,50 @@
1
+
2
+ /**
3
+ * @fileoverview Utility for extracting and normalizing domain names from URLs.
4
+ * Handles subdomains and TLD cleaning for source attribution.
5
+ */
6
+ /**
7
+ * Extract TLD and hostname from domain in Regex. There's [two or more part
8
+ * TLDs](https://en.wikipedia.org/wiki/List_of_Internet_top-level_domains)
9
+ * so it is hard to tell if host.secondTLD.tld or host.tld is correct way
10
+ * to get root domain (e.g. abc.go.jp, abc.co.uk)
11
+ * @param {string} domain
12
+ * @returns {string} rootDomain
13
+ */
14
+ export function convertURLToDomain(domain) {
15
+ var tldRegExp = new RegExp(
16
+ "(?=[^^]).(fr|de|cz|at|com|wiki|co|edu|gov|info|mil|id|" +
17
+ "gv|tv|int|name|net|org|pro|ac|me|ltd|parliament)(.|$).*$"
18
+ );
19
+ var match =
20
+ domain.match(tldRegExp) ||
21
+ domain.match(/(?=[^^])\.[^a-z]{1,2}\.[^\.]{2,4}$/) ||
22
+ domain.match(/\.[^\.]{2,}$/);
23
+ var tld = match && match.index;
24
+ var domainWithoutSuffix = domain.substring(0, tld);
25
+
26
+ // Get the main domain part, handling subdomains
27
+ if (domainWithoutSuffix.includes(".")) {
28
+ // Split by dots and get the last two parts for domains like en.wikipedia.org
29
+ const parts = domainWithoutSuffix.split(".");
30
+ if (parts.length >= 2) {
31
+ domainWithoutSuffix = parts.slice(-2).join(".");
32
+ } else {
33
+ domainWithoutSuffix = parts[parts.length - 1];
34
+ }
35
+ }
36
+ return domainWithoutSuffix;
37
+ }
38
+
39
+
40
+ /**
41
+ * Checks if a string is a valid URL.
42
+ * @param {string} string
43
+ * @returns {boolean} true if the string is a valid URL
44
+ * @private
45
+ */
46
+ export function isURLValid(string) {
47
+ return /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/
48
+ .test(string);
49
+ }
50
+
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @fileoverview extract-cite: cite any webpage.
3
+ *
4
+ * - `extractCite` reads author, date, title and source straight off the HTML
5
+ * (meta tags, selectors, JSON-LD, URL, a 92k-name database). Instant, offline,
6
+ * partial.
7
+ * - `extractCiteLLM` then asks a model (OpenRouter by default) to finish the
8
+ * citation, score every part, flag the doubtful ones and read the authors'
9
+ * qualifications, and writes it as APA, MLA, Chicago, Harvard, IEEE and BibTeX.
10
+ */
11
+ export * from "./html-to-cite/extract-cite";
12
+ export * from "./html-to-cite/url-to-domain";
13
+ export { extractHumanName } from "./html-to-cite/human-names-recognize";
14
+ export * from "./llm";
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @fileoverview The one LLM call: an OpenAI-compatible chat-completions
3
+ * request, OpenRouter by default, with a JSON-object reply parsed back out.
4
+ */
5
+
6
+ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
7
+ export const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
8
+
9
+ export class CiteLLMError extends Error {
10
+ status?: number;
11
+ constructor(message: string, status?: number) {
12
+ super(message);
13
+ this.name = "CiteLLMError";
14
+ this.status = status;
15
+ }
16
+ }
17
+
18
+ export interface CallLLMOptions {
19
+ apiKey?: string;
20
+ model?: string;
21
+ baseUrl?: string;
22
+ system: string;
23
+ user: string;
24
+ timeoutMs?: number;
25
+ headers?: Record<string, string>;
26
+ fetch?: typeof fetch;
27
+ }
28
+
29
+ /** The API key from the environment, where there is an environment. */
30
+ export function envApiKey(): string | undefined {
31
+ const env = typeof process !== "undefined" ? process.env : undefined;
32
+ return env?.OPENROUTER_API_KEY || undefined;
33
+ }
34
+
35
+ /**
36
+ * Sends one system + user prompt and returns the reply parsed as a JSON object.
37
+ * Throws `CiteLLMError` for a missing key, an HTTP error, an empty reply or a
38
+ * reply that is not JSON.
39
+ */
40
+ export async function callLLM(options: CallLLMOptions): Promise<{ json: unknown; model: string }> {
41
+ const apiKey = options.apiKey || envApiKey();
42
+ if (!apiKey) {
43
+ throw new CiteLLMError(
44
+ "No API key: pass `apiKey`, or set OPENROUTER_API_KEY.",
45
+ 401
46
+ );
47
+ }
48
+ const model = options.model || DEFAULT_MODEL;
49
+ const baseUrl = (options.baseUrl || OPENROUTER_BASE_URL).replace(/\/+$/, "");
50
+ const doFetch = options.fetch || fetch;
51
+
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 60000);
54
+ let res: Response;
55
+ try {
56
+ res = await doFetch(`${baseUrl}/chat/completions`, {
57
+ method: "POST",
58
+ signal: controller.signal,
59
+ headers: {
60
+ "Content-Type": "application/json",
61
+ Authorization: `Bearer ${apiKey}`,
62
+ ...options.headers,
63
+ },
64
+ body: JSON.stringify({
65
+ model,
66
+ temperature: 0,
67
+ response_format: { type: "json_object" },
68
+ messages: [
69
+ { role: "system", content: options.system },
70
+ { role: "user", content: options.user },
71
+ ],
72
+ }),
73
+ });
74
+ } catch (err) {
75
+ const aborted = (err as Error)?.name === "AbortError";
76
+ throw new CiteLLMError(
77
+ aborted ? "The model call timed out." : `The model call failed: ${(err as Error)?.message}`
78
+ );
79
+ } finally {
80
+ clearTimeout(timer);
81
+ }
82
+
83
+ const body: any = await res.json().catch(() => null);
84
+ if (!res.ok) {
85
+ const detail = body?.error?.message || body?.error || `HTTP ${res.status}`;
86
+ throw new CiteLLMError(`The model call failed: ${detail}`, res.status);
87
+ }
88
+ const content = body?.choices?.[0]?.message?.content;
89
+ if (typeof content !== "string" || !content.trim()) {
90
+ throw new CiteLLMError("The model returned an empty reply.", res.status);
91
+ }
92
+ return { json: parseJSONReply(content), model: body?.model || model };
93
+ }
94
+
95
+ /** Parses a reply that may be wrapped in a code fence or surrounded by prose. */
96
+ export function parseJSONReply(content: string): unknown {
97
+ const text = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
98
+ try {
99
+ return JSON.parse(text);
100
+ } catch {
101
+ const start = text.indexOf("{");
102
+ const end = text.lastIndexOf("}");
103
+ if (start >= 0 && end > start) {
104
+ try {
105
+ return JSON.parse(text.slice(start, end + 1));
106
+ } catch {
107
+ /* fall through */
108
+ }
109
+ }
110
+ throw new CiteLLMError("The model's reply was not valid JSON.");
111
+ }
112
+ }