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.
- package/README.md +56 -0
- package/dist/extract-cite.cjs.js +2 -0
- package/dist/extract-cite.cjs.js.map +1 -0
- package/dist/extract-cite.es.js +4 -0
- package/dist/extract-cite.es.js.map +1 -0
- package/dist/html-to-cite/extract-author.d.ts +11 -0
- package/dist/html-to-cite/extract-cite.d.ts +33 -0
- package/dist/html-to-cite/extract-date/date-extractors.d.ts +40 -0
- package/dist/html-to-cite/extract-date/date-validators.d.ts +15 -0
- package/dist/html-to-cite/extract-date/extract-date-quick.d.ts +8 -0
- package/dist/html-to-cite/extract-date/extract-date.d.ts +26 -0
- package/dist/html-to-cite/extract-source.d.ts +11 -0
- package/dist/html-to-cite/extract-title.d.ts +11 -0
- package/dist/html-to-cite/human-names-recognize.d.ts +16 -0
- package/dist/html-to-cite/metadata-to-cite.d.ts +16 -0
- package/dist/html-to-cite/url-to-domain.d.ts +20 -0
- package/dist/index.d.ts +14 -0
- package/dist/llm/call-llm.d.ts +33 -0
- package/dist/llm/extract-cite-llm.d.ts +19 -0
- package/dist/llm/format-citation.d.ts +22 -0
- package/dist/llm/index.d.ts +7 -0
- package/dist/llm/page-text.d.ts +21 -0
- package/dist/llm/parse-reply.d.ts +26 -0
- package/dist/llm/prompt.d.ts +14 -0
- package/dist/llm/types.d.ts +115 -0
- package/package.json +61 -0
- package/src/html-to-cite/extract-author.ts +125 -0
- package/src/html-to-cite/extract-cite.ts +97 -0
- package/src/html-to-cite/extract-date/date-extractors.ts +509 -0
- package/src/html-to-cite/extract-date/date-validators.ts +191 -0
- package/src/html-to-cite/extract-date/extract-date-quick.ts +184 -0
- package/src/html-to-cite/extract-date/extract-date.ts +1051 -0
- package/src/html-to-cite/extract-source.ts +35 -0
- package/src/html-to-cite/extract-title.ts +78 -0
- package/src/html-to-cite/human-names-92k.json +1 -0
- package/src/html-to-cite/human-names-recognize.ts +396 -0
- package/src/html-to-cite/metadata-to-cite.ts +77 -0
- package/src/html-to-cite/url-to-domain.ts +50 -0
- package/src/index.ts +14 -0
- package/src/llm/call-llm.ts +112 -0
- package/src/llm/extract-cite-llm.ts +100 -0
- package/src/llm/format-citation.ts +399 -0
- package/src/llm/index.ts +7 -0
- package/src/llm/page-text.ts +106 -0
- package/src/llm/parse-reply.ts +279 -0
- package/src/llm/prompt.ts +95 -0
- package/src/llm/types.ts +174 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `extractCiteLLM`: the cheap regex/metadata pass first, then one
|
|
3
|
+
* model call that is handed what was found and what is still missing, completes
|
|
4
|
+
* the APA parts, scores each one, and reads the authors' qualifications.
|
|
5
|
+
*/
|
|
6
|
+
import { parseHTML } from "linkedom";
|
|
7
|
+
import { extractCite } from "../html-to-cite/extract-cite";
|
|
8
|
+
import { callLLM } from "./call-llm";
|
|
9
|
+
import { formatCitations } from "./format-citation";
|
|
10
|
+
import { fetchPageHTML, preparePage } from "./page-text";
|
|
11
|
+
import { parseCitationReply } from "./parse-reply";
|
|
12
|
+
import { buildUserPrompt, missingFields, SYSTEM_PROMPT } from "./prompt";
|
|
13
|
+
import {
|
|
14
|
+
CITATION_STYLES,
|
|
15
|
+
type CitationField,
|
|
16
|
+
type ExtractCiteLLMOptions,
|
|
17
|
+
type ExtractCiteLLMResult,
|
|
18
|
+
} from "./types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Cites a page: regex first, then the model for what regex cannot do.
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* const cite = await extractCiteLLM({
|
|
25
|
+
* url: "https://www.nytimes.com/…",
|
|
26
|
+
* apiKey: process.env.OPENROUTER_API_KEY, // the default provider is OpenRouter
|
|
27
|
+
* model: "anthropic/claude-haiku-4.5",
|
|
28
|
+
* });
|
|
29
|
+
* cite.formatted.apa?.text; // full APA 7 entry
|
|
30
|
+
* cite.needsReview; // fields to check by hand
|
|
31
|
+
* cite.citation.authors[0].qualifications; // bio facts, quoted from the page
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* Throws when the page cannot be fetched or the model call fails; a missing
|
|
35
|
+
* author or date is not an error, it comes back flagged in `needsReview`.
|
|
36
|
+
*/
|
|
37
|
+
export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<ExtractCiteLLMResult> {
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
const { url, reviewThreshold = 0.7, styles = [...CITATION_STYLES] } = options;
|
|
40
|
+
|
|
41
|
+
let html = options.html;
|
|
42
|
+
if (!html && options.text === undefined) {
|
|
43
|
+
if (!url) throw new Error("Provide a `url`, `html` or `text` to cite.");
|
|
44
|
+
html = await fetchPageHTML(url, options.fetch);
|
|
45
|
+
}
|
|
46
|
+
html ??= `<html><body>${escapeText(options.text || "")}</body></html>`;
|
|
47
|
+
|
|
48
|
+
// 1. The partial citation: what the heuristics can read off the page.
|
|
49
|
+
const partial = extractCite(parseHTML(html).document, { url }) || {};
|
|
50
|
+
const missing: CitationField[] = missingFields(partial);
|
|
51
|
+
|
|
52
|
+
// 2. One model call with the partial in hand.
|
|
53
|
+
const page = preparePage(html, { text: options.text, maxChars: options.maxChars });
|
|
54
|
+
const { json, model } = await callLLM({
|
|
55
|
+
apiKey: options.apiKey,
|
|
56
|
+
model: options.model,
|
|
57
|
+
baseUrl: options.baseUrl,
|
|
58
|
+
headers: options.headers,
|
|
59
|
+
timeoutMs: options.timeoutMs,
|
|
60
|
+
fetch: options.fetch,
|
|
61
|
+
system: SYSTEM_PROMPT,
|
|
62
|
+
user: buildUserPrompt({ url, partial, missing, page }),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// 3. Validate it against the page and score it.
|
|
66
|
+
const accessedDate = options.accessedDate || new Date().toISOString().slice(0, 10);
|
|
67
|
+
const parsed = parseCitationReply(
|
|
68
|
+
json,
|
|
69
|
+
{ pageText: [page.pageTitle, page.meta, page.jsonLd, page.text].join("\n"), url, accessedDate },
|
|
70
|
+
partial,
|
|
71
|
+
reviewThreshold
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// `agree` means the regex pass and the model read the same value; anything
|
|
75
|
+
// else the model supplied, because the regex pass had nothing or was overruled.
|
|
76
|
+
const origin: ExtractCiteLLMResult["origin"] = {};
|
|
77
|
+
for (const field of Object.keys(parsed.confidence) as CitationField[]) {
|
|
78
|
+
const has = field === "authors" ? parsed.citation.authors.length > 0 : Boolean(parsed.citation[field]);
|
|
79
|
+
if (has) origin[field] = parsed.agreement[field] === "agree" ? "both" : "llm";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const required: CitationField[] = ["authors", "title", "containerTitle", "publishedDate"];
|
|
83
|
+
const overallConfidence =
|
|
84
|
+
required.reduce((sum, field) => sum + parsed.confidence[field], 0) / required.length;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
citation: parsed.citation,
|
|
88
|
+
confidence: parsed.confidence,
|
|
89
|
+
overallConfidence,
|
|
90
|
+
needsReview: parsed.needsReview,
|
|
91
|
+
partial,
|
|
92
|
+
missing,
|
|
93
|
+
origin,
|
|
94
|
+
formatted: formatCitations(parsed.citation, styles),
|
|
95
|
+
model,
|
|
96
|
+
ms: Date.now() - started,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const escapeText = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Writes a `CitationData` as an APA 7, MLA 9, Chicago
|
|
3
|
+
* (notes-bibliography), Harvard, IEEE or BibTeX entry. Pure and offline: no
|
|
4
|
+
* model call, so the same data can be re-styled for free.
|
|
5
|
+
*
|
|
6
|
+
* Italics are carried as private-use markers while a string is built, then
|
|
7
|
+
* turned into nothing (`text`) or `<i>` (`html`, with everything escaped).
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
CITATION_STYLES,
|
|
11
|
+
type CitationAuthor,
|
|
12
|
+
type CitationData,
|
|
13
|
+
type CitationStyle,
|
|
14
|
+
type FormattedCitation,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
const I0 = "";
|
|
18
|
+
const I1 = "";
|
|
19
|
+
const ital = (s: string) => `${I0}${s}${I1}`;
|
|
20
|
+
|
|
21
|
+
const MONTHS = [
|
|
22
|
+
"January", "February", "March", "April", "May", "June",
|
|
23
|
+
"July", "August", "September", "October", "November", "December",
|
|
24
|
+
];
|
|
25
|
+
const MONTHS_MLA = ["Jan.", "Feb.", "Mar.", "Apr.", "May", "June", "July", "Aug.", "Sept.", "Oct.", "Nov.", "Dec."];
|
|
26
|
+
const MONTHS_IEEE = ["Jan.", "Feb.", "Mar.", "Apr.", "May", "Jun.", "Jul.", "Aug.", "Sep.", "Oct.", "Nov.", "Dec."];
|
|
27
|
+
|
|
28
|
+
interface DateParts {
|
|
29
|
+
y?: number;
|
|
30
|
+
m?: number;
|
|
31
|
+
d?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseDateParts(iso: string | undefined): DateParts {
|
|
35
|
+
const match = iso?.match(/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/);
|
|
36
|
+
if (!match) return {};
|
|
37
|
+
return {
|
|
38
|
+
y: Number(match[1]),
|
|
39
|
+
m: match[2] ? Number(match[2]) : undefined,
|
|
40
|
+
d: match[3] ? Number(match[3]) : undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Adds a closing period unless the string already ends in terminal punctuation. */
|
|
45
|
+
const stop = (s: string) => (/[.?!][’"'\)]*$/.test(s) ? s : `${s}.`);
|
|
46
|
+
const join = (parts: (string | undefined | false)[], sep = " ") => parts.filter(Boolean).join(sep);
|
|
47
|
+
const stripProtocol = (url: string) => url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
|
48
|
+
|
|
49
|
+
/** `J. K.` from "Joanne Kathleen" / "Jean-Paul". */
|
|
50
|
+
function initials(given: string | undefined, spaced: boolean): string {
|
|
51
|
+
if (!given) return "";
|
|
52
|
+
const parts = given
|
|
53
|
+
.split(/\s+/)
|
|
54
|
+
.flatMap((word) => word.split("-").map((p, i, all) => ({ p, hyphen: i < all.length - 1 })))
|
|
55
|
+
.filter(({ p }) => p);
|
|
56
|
+
return parts
|
|
57
|
+
.map(({ p, hyphen }) => `${p.replace(/\.$/, "")[0].toUpperCase()}.${hyphen ? "-" : ""}`)
|
|
58
|
+
.join(spaced ? " " : "")
|
|
59
|
+
.replace(/-\s/g, "-");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Splits "Given Family" when the model gave only a full name. */
|
|
63
|
+
function nameParts(a: CitationAuthor): { given: string; family: string } {
|
|
64
|
+
if (a.family) return { given: a.given || "", family: a.family };
|
|
65
|
+
const words = a.name.split(/\s+/);
|
|
66
|
+
if (words.length === 1) return { given: "", family: words[0] };
|
|
67
|
+
return { given: words.slice(0, -1).join(" "), family: words[words.length - 1] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const withSuffix = (family: string, a: CitationAuthor) => (a.suffix ? `${family}, ${a.suffix}` : family);
|
|
71
|
+
|
|
72
|
+
/** "Family, Given" (inverted) or "Given Family" (natural), organizations untouched. */
|
|
73
|
+
function fullName(a: CitationAuthor, inverted: boolean): string {
|
|
74
|
+
if (a.isOrganization) return a.name;
|
|
75
|
+
const { given, family } = nameParts(a);
|
|
76
|
+
if (!given) return family;
|
|
77
|
+
return inverted ? `${family}, ${given}${a.suffix ? `, ${a.suffix}` : ""}` : `${given} ${withSuffix(family, a)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function initialName(a: CitationAuthor, opts: { inverted: boolean; spaced: boolean }): string {
|
|
81
|
+
if (a.isOrganization) return a.name;
|
|
82
|
+
const { given, family } = nameParts(a);
|
|
83
|
+
const ini = initials(given, opts.spaced);
|
|
84
|
+
if (!ini) return family;
|
|
85
|
+
return opts.inverted ? `${family}, ${ini}${a.suffix ? `, ${a.suffix}` : ""}` : `${ini} ${withSuffix(family, a)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function listNames(names: string[], joiner: string, serialComma: boolean): string {
|
|
89
|
+
if (names.length <= 1) return names[0] || "";
|
|
90
|
+
if (names.length === 2) return `${names[0]}${joiner === "&" ? "," : ""} ${joiner} ${names[1]}`;
|
|
91
|
+
return `${names.slice(0, -1).join(", ")}${serialComma ? "," : ""} ${joiner} ${names[names.length - 1]}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const isPeriodical = (c: CitationData) => c.sourceType === "journal-article";
|
|
95
|
+
/** Types whose title sits inside a larger work, so it gets quotes or no style. */
|
|
96
|
+
const isPartOfWork = (c: CitationData) =>
|
|
97
|
+
c.sourceType === "news-article" || c.sourceType === "journal-article" || c.sourceType === "blog-post";
|
|
98
|
+
|
|
99
|
+
function doiUrl(c: CitationData): string | undefined {
|
|
100
|
+
return c.doi ? `https://doi.org/${c.doi}` : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function apaDate(c: CitationData): string {
|
|
104
|
+
const { y, m, d } = parseDateParts(c.publishedDate);
|
|
105
|
+
if (!y) return "n.d.";
|
|
106
|
+
// Journals take the year alone; everything else the day when known.
|
|
107
|
+
if (isPeriodical(c)) return String(y);
|
|
108
|
+
return m ? `${y}, ${MONTHS[m - 1]}${d ? ` ${d}` : ""}` : String(y);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function apaAuthors(authors: CitationAuthor[]): string {
|
|
112
|
+
const names = authors.map((a) => initialName(a, { inverted: true, spaced: true }));
|
|
113
|
+
if (names.length > 20) return `${names.slice(0, 19).join(", ")}, . . . ${names[names.length - 1]}`;
|
|
114
|
+
return listNames(names, "&", true);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatAPA(c: CitationData): string {
|
|
118
|
+
const authors = apaAuthors(c.authors);
|
|
119
|
+
const date = `(${apaDate(c)}).`;
|
|
120
|
+
const doi = doiUrl(c);
|
|
121
|
+
// An undated page may change, so APA says when it was read.
|
|
122
|
+
const retrieved = parseDateParts(c.accessedDate);
|
|
123
|
+
const link =
|
|
124
|
+
doi ||
|
|
125
|
+
(c.url && !c.publishedDate && retrieved.y && retrieved.m && retrieved.d
|
|
126
|
+
? `Retrieved ${MONTHS[retrieved.m - 1]} ${retrieved.d}, ${retrieved.y}, from ${c.url}`
|
|
127
|
+
: c.url);
|
|
128
|
+
const title = c.title || "Untitled";
|
|
129
|
+
const container = c.containerTitle;
|
|
130
|
+
// A site that is also the author is not repeated (APA 7, 9.20).
|
|
131
|
+
const sameAsAuthor =
|
|
132
|
+
container && c.authors.length === 1 && c.authors[0].name.toLowerCase() === container.toLowerCase();
|
|
133
|
+
|
|
134
|
+
let heading: string;
|
|
135
|
+
let rest: string | undefined;
|
|
136
|
+
if (isPeriodical(c)) {
|
|
137
|
+
heading = stop(title);
|
|
138
|
+
const vol = join([c.volume && ital(c.volume), c.issue && `(${c.issue})`], "");
|
|
139
|
+
const tail = [container && ital(container), vol || undefined, c.pages].filter(Boolean).join(", ");
|
|
140
|
+
rest = tail && stop(tail);
|
|
141
|
+
} else if (isPartOfWork(c)) {
|
|
142
|
+
heading = stop(title);
|
|
143
|
+
rest = container && stop(ital(container));
|
|
144
|
+
} else {
|
|
145
|
+
heading = stop(`${ital(title)}${c.sourceType === "video" ? " [Video]" : ""}`);
|
|
146
|
+
const site = c.publisher || (sameAsAuthor ? undefined : container);
|
|
147
|
+
rest = site && stop(site);
|
|
148
|
+
}
|
|
149
|
+
// No author: the title leads and the date follows it.
|
|
150
|
+
const lead = authors ? [stop(authors), date, heading] : [heading, date];
|
|
151
|
+
return join([...lead, rest, link]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function mlaDate(iso: string | undefined): string | undefined {
|
|
155
|
+
const { y, m, d } = parseDateParts(iso);
|
|
156
|
+
if (!y) return undefined;
|
|
157
|
+
return join([d && String(d), m && MONTHS_MLA[m - 1], String(y)]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function mlaAuthors(authors: CitationAuthor[]): string {
|
|
161
|
+
if (authors.length === 0) return "";
|
|
162
|
+
if (authors.length === 1) return fullName(authors[0], true);
|
|
163
|
+
if (authors.length === 2) return `${fullName(authors[0], true)}, and ${fullName(authors[1], false)}`;
|
|
164
|
+
return `${fullName(authors[0], true)}, et al`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function formatMLA(c: CitationData): string {
|
|
168
|
+
// A site that is its own author is named once, as the publisher.
|
|
169
|
+
const selfAuthored =
|
|
170
|
+
c.authors.length === 1 && c.authors[0].isOrganization && c.authors[0].name.toLowerCase() === (c.containerTitle || "").toLowerCase();
|
|
171
|
+
const authors = selfAuthored ? "" : mlaAuthors(c.authors);
|
|
172
|
+
const date = mlaDate(c.publishedDate);
|
|
173
|
+
const link = doiUrl(c) || (c.url ? stripProtocol(c.url) : undefined);
|
|
174
|
+
const title = c.title || "Untitled";
|
|
175
|
+
const container = c.containerTitle;
|
|
176
|
+
|
|
177
|
+
let rest: string;
|
|
178
|
+
if (isPartOfWork(c)) {
|
|
179
|
+
rest = join(
|
|
180
|
+
[
|
|
181
|
+
[
|
|
182
|
+
container && ital(container),
|
|
183
|
+
c.sourceType === "journal-article" ? c.volume && `vol. ${c.volume}` : c.publisher !== container && c.publisher,
|
|
184
|
+
c.sourceType === "journal-article" && c.issue && `no. ${c.issue}`,
|
|
185
|
+
date,
|
|
186
|
+
c.pages && `pp. ${c.pages}`,
|
|
187
|
+
link,
|
|
188
|
+
]
|
|
189
|
+
.filter(Boolean)
|
|
190
|
+
.join(", "),
|
|
191
|
+
],
|
|
192
|
+
" "
|
|
193
|
+
);
|
|
194
|
+
} else {
|
|
195
|
+
rest = [c.publisher || container, date, link].filter(Boolean).join(", ");
|
|
196
|
+
}
|
|
197
|
+
const heading = isPartOfWork(c) ? `“${stop(title)}”` : stop(ital(title));
|
|
198
|
+
return join([authors && stop(authors), heading, rest && stop(rest)]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function chicagoDate(iso: string | undefined): string | undefined {
|
|
202
|
+
const { y, m, d } = parseDateParts(iso);
|
|
203
|
+
if (!y) return undefined;
|
|
204
|
+
return m ? `${MONTHS[m - 1]}${d ? ` ${d}` : ""}, ${y}` : String(y);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function chicagoAuthors(authors: CitationAuthor[]): string {
|
|
208
|
+
if (authors.length === 0) return "";
|
|
209
|
+
const names = (authors.length > 10 ? authors.slice(0, 7) : authors).map((a, i) => fullName(a, i === 0));
|
|
210
|
+
if (authors.length > 10) return `${names.join(", ")}, et al`;
|
|
211
|
+
return names.length === 2 ? `${names[0]}, and ${names[1]}` : listNames(names, "and", true);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function formatChicago(c: CitationData, accessed?: string): string {
|
|
215
|
+
const authors = chicagoAuthors(c.authors);
|
|
216
|
+
const date = chicagoDate(c.publishedDate);
|
|
217
|
+
const link = doiUrl(c) || c.url;
|
|
218
|
+
const title = c.title || "Untitled";
|
|
219
|
+
const container = c.containerTitle;
|
|
220
|
+
const access = !date && accessed ? `Accessed ${chicagoDate(accessed)}.` : undefined;
|
|
221
|
+
|
|
222
|
+
if (isPeriodical(c)) {
|
|
223
|
+
const issue = join([container && ital(container), [c.volume, c.issue && `no. ${c.issue}`].filter(Boolean).join(", ")]);
|
|
224
|
+
const where = join([issue, date && `(${parseDateParts(c.publishedDate).y})`, c.pages && `: ${c.pages}`], " ").replace(/ :/, ":");
|
|
225
|
+
return join([authors && stop(authors), `“${stop(title)}”`, stop(where), link && stop(link)]);
|
|
226
|
+
}
|
|
227
|
+
if (c.sourceType === "news-article") {
|
|
228
|
+
return join([
|
|
229
|
+
authors && stop(authors),
|
|
230
|
+
`“${stop(title)}”`,
|
|
231
|
+
stop([container && ital(container), date].filter(Boolean).join(", ")),
|
|
232
|
+
access,
|
|
233
|
+
link && stop(link),
|
|
234
|
+
]);
|
|
235
|
+
}
|
|
236
|
+
if (c.sourceType === "webpage" || c.sourceType === "blog-post") {
|
|
237
|
+
return join([
|
|
238
|
+
authors && stop(authors),
|
|
239
|
+
`“${stop(title)}”`,
|
|
240
|
+
container && stop(container),
|
|
241
|
+
c.publisher && c.publisher !== container && stop(c.publisher),
|
|
242
|
+
date && stop(date),
|
|
243
|
+
access,
|
|
244
|
+
link && stop(link),
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
return join([
|
|
248
|
+
authors && stop(authors),
|
|
249
|
+
stop(ital(title)),
|
|
250
|
+
c.publisher && stop(c.publisher),
|
|
251
|
+
date && stop(date),
|
|
252
|
+
access,
|
|
253
|
+
link && stop(link),
|
|
254
|
+
]);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function harvardAuthors(authors: CitationAuthor[]): string {
|
|
258
|
+
if (authors.length === 0) return "";
|
|
259
|
+
if (authors.length > 3) return `${initialName(authors[0], { inverted: true, spaced: false })} et al.`;
|
|
260
|
+
return listNames(authors.map((a) => initialName(a, { inverted: true, spaced: false })), "and", false);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function formatHarvard(c: CitationData, accessed?: string): string {
|
|
264
|
+
const { y, m, d } = parseDateParts(c.publishedDate);
|
|
265
|
+
const authors = harvardAuthors(c.authors);
|
|
266
|
+
const year = y ? `(${y})` : "(no date)";
|
|
267
|
+
const title = c.title || "Untitled";
|
|
268
|
+
const link = doiUrl(c) || c.url;
|
|
269
|
+
const acc = accessed ? parseDateParts(accessed) : undefined;
|
|
270
|
+
const accessText = acc?.y && !(isPeriodical(c) && c.doi) ? `(Accessed: ${acc.d} ${MONTHS[(acc.m || 1) - 1]} ${acc.y}).` : undefined;
|
|
271
|
+
|
|
272
|
+
let heading: string;
|
|
273
|
+
let rest: string | undefined;
|
|
274
|
+
if (isPartOfWork(c)) {
|
|
275
|
+
heading = `\u2018${title}\u2019,`;
|
|
276
|
+
const tail = [c.containerTitle && ital(c.containerTitle), c.volume, c.issue && `(${c.issue})`, c.pages && `pp. ${c.pages}`]
|
|
277
|
+
.filter(Boolean)
|
|
278
|
+
.join(", ")
|
|
279
|
+
.replace(", (", "(");
|
|
280
|
+
// Day and month are for dated pieces; journals are cited by year and volume.
|
|
281
|
+
const day = !isPeriodical(c) && m ? `${d ? `${d} ` : ""}${MONTHS[m - 1]}` : undefined;
|
|
282
|
+
rest = stop([tail, day].filter(Boolean).join(", "));
|
|
283
|
+
} else {
|
|
284
|
+
heading = stop(ital(title));
|
|
285
|
+
rest = c.publisher && stop(c.publisher);
|
|
286
|
+
}
|
|
287
|
+
const lead = authors ? [authors, year, heading] : [isPartOfWork(c) ? heading.replace(/,$/, "") : heading, year];
|
|
288
|
+
return join([...lead, rest, link && `Available at: ${link}`, accessText]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function ieeeAuthors(authors: CitationAuthor[]): string {
|
|
292
|
+
if (authors.length === 0) return "";
|
|
293
|
+
if (authors.length > 6) return `${initialName(authors[0], { inverted: false, spaced: true })} et al.`;
|
|
294
|
+
const names = authors.map((a) => initialName(a, { inverted: false, spaced: true }));
|
|
295
|
+
return names.length === 2 ? `${names[0]} and ${names[1]}` : listNames(names, "and", true);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function formatIEEE(c: CitationData, accessed?: string): string {
|
|
299
|
+
const { y, m, d } = parseDateParts(c.publishedDate);
|
|
300
|
+
const authors = ieeeAuthors(c.authors);
|
|
301
|
+
const title = c.title || "Untitled";
|
|
302
|
+
const date = y ? join([m && MONTHS_IEEE[m - 1], m && d && `${d},`, String(y)]) : "n.d";
|
|
303
|
+
const acc = accessed ? parseDateParts(accessed) : undefined;
|
|
304
|
+
const accessText = acc?.y ? `(accessed ${MONTHS_IEEE[(acc.m || 1) - 1]} ${acc.d}, ${acc.y}).` : undefined;
|
|
305
|
+
|
|
306
|
+
const parts: string[] = [];
|
|
307
|
+
if (authors) parts.push(`${authors},`);
|
|
308
|
+
parts.push(c.sourceType === "book" ? `${ital(title)},` : `“${title},”`);
|
|
309
|
+
if (isPeriodical(c)) {
|
|
310
|
+
const tail = [c.containerTitle && ital(c.containerTitle), c.volume && `vol. ${c.volume}`, c.issue && `no. ${c.issue}`, c.pages && `pp. ${c.pages}`, date].filter(Boolean);
|
|
311
|
+
parts.push(`${tail.join(", ")}${c.doi ? `, doi: ${c.doi}.` : "."}`);
|
|
312
|
+
} else {
|
|
313
|
+
const tail = [c.containerTitle && c.containerTitle !== title && (c.sourceType === "book" ? c.containerTitle : ital(c.containerTitle)), c.publisher, date].filter(Boolean);
|
|
314
|
+
parts.push(`${tail.join(", ")}.`);
|
|
315
|
+
if (c.url) parts.push(`[Online]. Available: ${c.url}`);
|
|
316
|
+
if (accessText) parts.push(accessText);
|
|
317
|
+
}
|
|
318
|
+
return parts.join(" ").replace(/,\s*\./g, ".");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const BIB_ESCAPES: Record<string, string> = { "\\": "\\textbackslash{}", "{": "\\{", "}": "\\}", "&": "\\&", "%": "\\%", "$": "\\$", "#": "\\#", "_": "\\_", "~": "\\textasciitilde{}", "^": "\\textasciicircum{}" };
|
|
322
|
+
const bibEscape = (s: string) => s.replace(/[\\{}&%$#_~^]/g, (ch) => BIB_ESCAPES[ch]);
|
|
323
|
+
|
|
324
|
+
export function bibtexKey(c: CitationData): string {
|
|
325
|
+
const first = c.authors[0];
|
|
326
|
+
const who = first ? (first.isOrganization ? first.name : nameParts(first).family) : "anon";
|
|
327
|
+
const word = (c.title || "").split(/\s+/).find((w) => w.replace(/\W/g, "").length > 3) || "";
|
|
328
|
+
const { y } = parseDateParts(c.publishedDate);
|
|
329
|
+
return `${who}${y ?? ""}${word}`.normalize("NFD").replace(/[^A-Za-z0-9]/g, "").toLowerCase() || "cite";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function formatBibTeX(c: CitationData, accessed?: string): string {
|
|
333
|
+
const { y, m, d } = parseDateParts(c.publishedDate);
|
|
334
|
+
const type = c.sourceType === "journal-article" || c.sourceType === "news-article" ? "article" : c.sourceType === "book" ? "book" : c.sourceType === "report" ? "techreport" : "misc";
|
|
335
|
+
const fields: [string, string | undefined][] = [
|
|
336
|
+
["author", c.authors.length ? c.authors.map((a) => (a.isOrganization ? `{${bibEscape(a.name)}}` : bibEscape(fullName(a, true)))).join(" and ") : undefined],
|
|
337
|
+
["title", c.title && bibEscape(c.title)],
|
|
338
|
+
[type === "article" ? "journal" : "booktitle", type === "misc" ? undefined : c.containerTitle && bibEscape(c.containerTitle)],
|
|
339
|
+
["publisher", (c.publisher || (type === "misc" ? c.containerTitle : undefined)) && bibEscape((c.publisher || c.containerTitle)!)],
|
|
340
|
+
["year", y ? String(y) : undefined],
|
|
341
|
+
["month", m ? MONTHS[m - 1].slice(0, 3).toLowerCase() : undefined],
|
|
342
|
+
["day", d ? String(d) : undefined],
|
|
343
|
+
["volume", c.volume && bibEscape(c.volume)],
|
|
344
|
+
["number", c.issue && bibEscape(c.issue)],
|
|
345
|
+
["pages", c.pages && c.pages.replace(/[–—-]+/g, "--")],
|
|
346
|
+
["doi", c.doi],
|
|
347
|
+
["url", c.url],
|
|
348
|
+
["urldate", accessed],
|
|
349
|
+
];
|
|
350
|
+
const lines = fields
|
|
351
|
+
.filter(([, v]) => v)
|
|
352
|
+
.map(([k, v]) => {
|
|
353
|
+
// `month` is a bare macro (mar), everything else is braced.
|
|
354
|
+
return ` ${k} = ${k === "month" ? v : `{${v}}`}`;
|
|
355
|
+
});
|
|
356
|
+
return `@${type}{${bibtexKey(c)},\n${lines.join(",\n")}\n}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const escapeHTML = (s: string) =>
|
|
360
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
361
|
+
|
|
362
|
+
/** Italic markers → nothing, or `<i>` once the rest is escaped. */
|
|
363
|
+
function render(marked: string): FormattedCitation {
|
|
364
|
+
const text = marked.split(I0).join("").split(I1).join("").replace(/\s+/g, (ws) => (ws.includes("\n") ? ws : " ")).trim();
|
|
365
|
+
const html = escapeHTML(marked).split(I0).join("<i>").split(I1).join("</i>").replace(/[ \t]+/g, " ").trim();
|
|
366
|
+
return { text, html: html.replace(/\n/g, "<br>") };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Writes `citation` in one style. `accessedDate` (ISO) defaults to the one on
|
|
371
|
+
* the citation; styles that print an access date use it when the page is undated.
|
|
372
|
+
*/
|
|
373
|
+
export function formatCitation(citation: CitationData, style: CitationStyle): FormattedCitation {
|
|
374
|
+
const accessed = citation.accessedDate;
|
|
375
|
+
switch (style) {
|
|
376
|
+
case "apa":
|
|
377
|
+
return render(formatAPA(citation));
|
|
378
|
+
case "mla":
|
|
379
|
+
return render(formatMLA(citation));
|
|
380
|
+
case "chicago":
|
|
381
|
+
return render(formatChicago(citation, accessed));
|
|
382
|
+
case "harvard":
|
|
383
|
+
return render(formatHarvard(citation, accessed));
|
|
384
|
+
case "ieee":
|
|
385
|
+
return render(formatIEEE(citation, accessed));
|
|
386
|
+
case "bibtex": {
|
|
387
|
+
const text = formatBibTeX(citation, accessed);
|
|
388
|
+
return { text, html: escapeHTML(text).replace(/\n/g, "<br>") };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Every requested style at once. */
|
|
394
|
+
export function formatCitations(
|
|
395
|
+
citation: CitationData,
|
|
396
|
+
styles: CitationStyle[] = [...CITATION_STYLES]
|
|
397
|
+
): Partial<Record<CitationStyle, FormattedCitation>> {
|
|
398
|
+
return Object.fromEntries(styles.map((style) => [style, formatCitation(citation, style)]));
|
|
399
|
+
}
|
package/src/llm/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./types";
|
|
2
|
+
export * from "./call-llm";
|
|
3
|
+
export * from "./extract-cite-llm";
|
|
4
|
+
export * from "./format-citation";
|
|
5
|
+
export { preparePage, fetchPageHTML, clipText } from "./page-text";
|
|
6
|
+
export { parseCitationReply, normalizeDate } from "./parse-reply";
|
|
7
|
+
export { SYSTEM_PROMPT, buildUserPrompt, missingFields } from "./prompt";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Turns a page into what the model is shown: its plain text
|
|
3
|
+
* (head and tail, since bylines open an article and bios close it), the
|
|
4
|
+
* citation-bearing `<meta>` tags, and any JSON-LD.
|
|
5
|
+
*/
|
|
6
|
+
import { parseHTML } from "linkedom";
|
|
7
|
+
|
|
8
|
+
const REMOVE = "script,style,noscript,svg,template,iframe,canvas";
|
|
9
|
+
const META_KEEP =
|
|
10
|
+
/^(author|byl|description|date|pubdate|publish|citation_|dc[.:]|dcterms|og:(title|site_name|type|article|description)|article:|twitter:(site|creator|title)|parsely-|sailthru\.|prism\.|DC\.)/i;
|
|
11
|
+
|
|
12
|
+
export interface PageInput {
|
|
13
|
+
/** Visible text, whitespace-collapsed and trimmed to the budget. */
|
|
14
|
+
text: string;
|
|
15
|
+
/** `name: content` lines for the meta tags that carry citation data. */
|
|
16
|
+
meta: string;
|
|
17
|
+
/** JSON-LD blocks, trimmed. */
|
|
18
|
+
jsonLd: string;
|
|
19
|
+
/** The page's `<title>`. */
|
|
20
|
+
pageTitle: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fetches a page's HTML, throwing a readable error when it cannot. */
|
|
24
|
+
export async function fetchPageHTML(
|
|
25
|
+
url: string,
|
|
26
|
+
doFetch: typeof fetch = fetch,
|
|
27
|
+
timeoutMs = 15000
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
let target: URL;
|
|
30
|
+
try {
|
|
31
|
+
target = new URL(url);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error("`url` is not a valid URL.");
|
|
34
|
+
}
|
|
35
|
+
if (target.protocol !== "http:" && target.protocol !== "https:")
|
|
36
|
+
throw new Error("Only http(s) URLs are supported.");
|
|
37
|
+
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
40
|
+
try {
|
|
41
|
+
const res = await doFetch(target.href, {
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
redirect: "follow",
|
|
44
|
+
headers: {
|
|
45
|
+
Accept: "text/html,application/xhtml+xml",
|
|
46
|
+
"User-Agent":
|
|
47
|
+
"Mozilla/5.0 (compatible; extract-cite/1.0; +https://github.com/OpenSourceAGI/qwksearch-research-agent)",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) throw new Error(`Fetching the page failed with HTTP ${res.status}.`);
|
|
51
|
+
const type = res.headers.get("content-type") || "";
|
|
52
|
+
if (type && !/html|xml|text\//i.test(type))
|
|
53
|
+
throw new Error(`The URL is not a webpage (${type.split(";")[0]}).`);
|
|
54
|
+
return await res.text();
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Collapses whitespace. */
|
|
61
|
+
const squash = (value: string) => value.replace(/\s+/g, " ").trim();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Head and tail of `text` within `max` characters: 3/4 from the start, 1/4 from
|
|
65
|
+
* the end, with a marker between. Short text is returned whole.
|
|
66
|
+
*/
|
|
67
|
+
export function clipText(text: string, max: number): string {
|
|
68
|
+
if (text.length <= max) return text;
|
|
69
|
+
const tail = Math.floor(max / 4);
|
|
70
|
+
return `${text.slice(0, max - tail)}\n[…]\n${text.slice(text.length - tail)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function preparePage(
|
|
74
|
+
html: string,
|
|
75
|
+
options: { text?: string; maxChars?: number } = {}
|
|
76
|
+
): PageInput {
|
|
77
|
+
const { document } = parseHTML(html);
|
|
78
|
+
|
|
79
|
+
const meta: string[] = [];
|
|
80
|
+
for (const el of Array.from(document.querySelectorAll("meta"))) {
|
|
81
|
+
const key = el.getAttribute("name") || el.getAttribute("property") || el.getAttribute("itemprop");
|
|
82
|
+
const content = el.getAttribute("content");
|
|
83
|
+
if (key && content && META_KEEP.test(key)) meta.push(`${key}: ${squash(content).slice(0, 300)}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const jsonLd = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
|
|
87
|
+
.map((el) => squash(el.textContent || ""))
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.join("\n")
|
|
90
|
+
.slice(0, 4000);
|
|
91
|
+
|
|
92
|
+
const pageTitle = squash(document.querySelector("title")?.textContent || "");
|
|
93
|
+
|
|
94
|
+
let text = options.text;
|
|
95
|
+
if (text === undefined) {
|
|
96
|
+
for (const el of Array.from(document.querySelectorAll(REMOVE))) el.remove();
|
|
97
|
+
text = squash(document.body?.textContent || document.documentElement?.textContent || "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
text: clipText(squash(text), options.maxChars ?? 12000),
|
|
102
|
+
meta: meta.slice(0, 60).join("\n"),
|
|
103
|
+
jsonLd,
|
|
104
|
+
pageTitle,
|
|
105
|
+
};
|
|
106
|
+
}
|