@coywolf/emdash 0.0.0-stage → 0.35.13
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/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Wikidata client: the SPARQL details query, parsing its
|
|
3
|
+
* results, and how non-OK answers (429, maxlag) are handled.
|
|
4
|
+
* Run: node --test test/*.test.mjs
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
|
|
10
|
+
// @ts-ignore -- Node runs the .ts source directly; tsc doesn't need to resolve it.
|
|
11
|
+
import * as L from "./logic.ts";
|
|
12
|
+
// @ts-ignore -- as above.
|
|
13
|
+
import * as W from "./wikidata.ts";
|
|
14
|
+
|
|
15
|
+
const logic = L as typeof import("./logic.js");
|
|
16
|
+
const wikidata = W as typeof import("./wikidata.js");
|
|
17
|
+
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`../../test/fixtures/${name}`, import.meta.url), "utf8"));
|
|
18
|
+
const UA = "CoywolfPack/0.0.0 (https://example.com) EmDash";
|
|
19
|
+
|
|
20
|
+
/** A fetcher that records requests and answers with the given responses in turn. */
|
|
21
|
+
function fakeFetch(...responses: Response[]) {
|
|
22
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
23
|
+
const fetcher = async (url: string, init?: RequestInit) => {
|
|
24
|
+
calls.push({ url, init });
|
|
25
|
+
const next = responses.shift();
|
|
26
|
+
if (!next) throw new Error("unexpected request");
|
|
27
|
+
return next;
|
|
28
|
+
};
|
|
29
|
+
return { fetcher, calls };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Run `fn` with console.warn captured. */
|
|
33
|
+
async function warnings<T>(fn: () => Promise<T>): Promise<{ result?: T; error?: unknown; warned: unknown[][] }> {
|
|
34
|
+
const warned: unknown[][] = [];
|
|
35
|
+
const original = console.warn;
|
|
36
|
+
console.warn = (...args: unknown[]) => void warned.push(args);
|
|
37
|
+
try {
|
|
38
|
+
return { result: await fn(), warned };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return { error, warned };
|
|
41
|
+
} finally {
|
|
42
|
+
console.warn = original;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const json = (body: unknown, init: ResponseInit = {}) => new Response(JSON.stringify(body), { ...init, headers: { "content-type": "application/json", ...(init.headers ?? {}) } });
|
|
47
|
+
|
|
48
|
+
test("detailsQuery takes only Q-ids, once each, and the sitelink only for a plain language code", () => {
|
|
49
|
+
const q = logic.detailsQuery(["Q90", "Q90", "q5", "P31", "Q1 } . ?x ?y ?z", "Q42"], "en");
|
|
50
|
+
assert.match(q, /VALUES \?item \{ wd:Q90 wd:Q42 \}/);
|
|
51
|
+
assert.doesNotMatch(q, /\?x|P31 \}|q5/);
|
|
52
|
+
assert.match(q, /schema:isPartOf <https:\/\/en\.wikipedia\.org\/>/);
|
|
53
|
+
assert.match(q, /FILTER\(\?r != wikibase:DeprecatedRank\)/);
|
|
54
|
+
assert.match(q, /SELECT \?item \?class \?website \?rank \?title/);
|
|
55
|
+
assert.match(q, /ps:P856 \?website ; wikibase:rank \?rank \. FILTER\(\?rank != wikibase:DeprecatedRank\)/);
|
|
56
|
+
assert.doesNotMatch(logic.detailsQuery(["Q1"], "e>n"), /wikipedia/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("parseSparqlDetails gives the same details the wbgetentities parser did (recorded Q90, Q42, Q95, Q1, Q2)", () => {
|
|
60
|
+
const parsed = logic.parseSparqlDetails(fixture("wikidata-details.sparql.json"), "en");
|
|
61
|
+
const expected: Record<string, { p31: string[] }> = fixture("wikidata-details.expected.json");
|
|
62
|
+
// SPARQL rows have no statement order, and P31 order doesn't matter to verification.
|
|
63
|
+
const sorted = (o: Record<string, { p31: string[] }>) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, { ...v, p31: [...v.p31].sort() }]));
|
|
64
|
+
assert.deepEqual(sorted(parsed!), sorted(expected));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("parseSparqlDetails keeps Q-ids and http(s) websites only, and rejects non-SPARQL bodies", () => {
|
|
68
|
+
const row = (fields: Record<string, string>) => Object.fromEntries(Object.entries(fields).map(([k, value]) => [k, { type: "uri", value }]));
|
|
69
|
+
const E = "http://www.wikidata.org/entity/";
|
|
70
|
+
const out = logic.parseSparqlDetails(
|
|
71
|
+
{
|
|
72
|
+
results: {
|
|
73
|
+
bindings: [
|
|
74
|
+
row({ item: `${E}Q7`, class: `${E}Q5` }),
|
|
75
|
+
row({ item: `${E}Q7`, class: `${E}Q5` }),
|
|
76
|
+
row({ item: `${E}Q7`, class: "http://www.wikidata.org/.well-known/genid/abc" }),
|
|
77
|
+
row({ item: `${E}Q7`, website: "ftp://example.com/" }),
|
|
78
|
+
row({ item: `${E}Q7`, website: "https://example.com/" }),
|
|
79
|
+
row({ item: `${E}Q7`, title: "A/B: C d" }),
|
|
80
|
+
row({ item: `${E}L1`, class: `${E}Q5` }),
|
|
81
|
+
row({ item: `${E}Q8` }),
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
"en",
|
|
86
|
+
);
|
|
87
|
+
assert.deepEqual(out, {
|
|
88
|
+
Q7: { p31: ["Q5"], wikipedia: "https://en.wikipedia.org/wiki/A/B:_C_d", website: "https://example.com/" },
|
|
89
|
+
Q8: { p31: [], wikipedia: "", website: "" },
|
|
90
|
+
});
|
|
91
|
+
assert.equal(logic.parseSparqlDetails({ error: "nope" }, "en"), null);
|
|
92
|
+
assert.equal(logic.parseSparqlDetails(null, "en"), null);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("parseSparqlDetails picks one website regardless of row order: preferred rank, then https, then the smallest URL", () => {
|
|
96
|
+
const E = "http://www.wikidata.org/entity/";
|
|
97
|
+
const R = "http://wikiba.se/ontology#";
|
|
98
|
+
const site = (qid: string, website: string, rank = "NormalRank") => ({ item: { type: "uri", value: `${E}${qid}` }, website: { type: "uri", value: website }, rank: { type: "uri", value: `${R}${rank}` } });
|
|
99
|
+
const rows = [
|
|
100
|
+
site("Q1", "https://zzz.example/"),
|
|
101
|
+
site("Q1", "http://preferred.example/", "PreferredRank"),
|
|
102
|
+
site("Q1", "https://aaa.example/"),
|
|
103
|
+
site("Q2", "http://aaa.example/"),
|
|
104
|
+
site("Q2", "https://zzz.example/"),
|
|
105
|
+
site("Q3", "https://b.example/"),
|
|
106
|
+
site("Q3", "https://a.example/"),
|
|
107
|
+
];
|
|
108
|
+
const pick = (bindings: unknown[]) => Object.fromEntries(Object.entries(logic.parseSparqlDetails({ results: { bindings } }, "en")!).map(([k, v]) => [k, v.website]));
|
|
109
|
+
const expected = { Q1: "http://preferred.example/", Q2: "https://zzz.example/", Q3: "https://a.example/" };
|
|
110
|
+
assert.deepEqual(pick(rows), expected);
|
|
111
|
+
assert.deepEqual(pick([...rows].reverse()), expected);
|
|
112
|
+
// The recorded Q90 lists two websites with the http one first: the https one is kept.
|
|
113
|
+
assert.equal(logic.parseSparqlDetails(fixture("wikidata-details.sparql.json"), "en")!.Q90.website, "https://www.paris.fr/");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("userAgent follows the Wikimedia policy, with the repository as contact when the site has no URL", () => {
|
|
117
|
+
assert.equal(logic.userAgent("1.2.3", "https://example.com"), "CoywolfPack/1.2.3 (https://example.com) EmDash");
|
|
118
|
+
assert.equal(logic.userAgent("1.2.3", undefined), "CoywolfPack/1.2.3 (https://github.com/coywolf-llc/coywolf-pack) EmDash");
|
|
119
|
+
assert.equal(logic.userAgent("1.2.3", ""), "CoywolfPack/1.2.3 (https://github.com/coywolf-llc/coywolf-pack) EmDash");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("entityDetails makes one SPARQL request to query.wikidata.org", async () => {
|
|
123
|
+
const { fetcher, calls } = fakeFetch(json(fixture("wikidata-details.sparql.json")));
|
|
124
|
+
const details = await wikidata.entityDetails(fetcher, ["Q90", "Q42", "Q95", "Q1", "Q2", "bogus"], "en", UA);
|
|
125
|
+
assert.equal(calls.length, 1);
|
|
126
|
+
const url = new URL(calls[0].url);
|
|
127
|
+
assert.equal(url.origin + url.pathname, `https://${wikidata.WIKIDATA_QUERY_HOST}/sparql`);
|
|
128
|
+
assert.equal(url.searchParams.get("format"), "json");
|
|
129
|
+
assert.match(url.searchParams.get("query")!, /wd:Q90 wd:Q42 wd:Q95 wd:Q1 wd:Q2 \}/);
|
|
130
|
+
const headers = calls[0].init?.headers as Record<string, string>;
|
|
131
|
+
assert.equal(headers["user-agent"], UA);
|
|
132
|
+
assert.equal(headers.accept, "application/sparql-results+json");
|
|
133
|
+
assert.deepEqual(Object.keys(details!).sort(), ["Q1", "Q2", "Q42", "Q90", "Q95"]);
|
|
134
|
+
// Nothing to look up: no request.
|
|
135
|
+
assert.deepEqual(await wikidata.entityDetails(fakeFetch().fetcher, ["P31"], "en", UA), {});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a non-OK answer is logged with its status and Retry-After; a 500 means 'didn't answer'", async () => {
|
|
139
|
+
const { result, warned } = await warnings(() => wikidata.entityDetails(fakeFetch(new Response("down", { status: 503, headers: { "retry-after": "30" } })).fetcher, ["Q1"], "en", UA));
|
|
140
|
+
assert.equal(result, null);
|
|
141
|
+
assert.equal(warned.length, 1);
|
|
142
|
+
assert.match(String(warned[0][0]), /query\.wikidata\.org.*HTTP 503/);
|
|
143
|
+
assert.deepEqual(warned[0][1], { retryAfter: "30" });
|
|
144
|
+
// Search: an empty result that isn't cached, so the next run asks again.
|
|
145
|
+
const search = fakeFetch(new Response("down", { status: 502 }), json({ search: [{ id: "Q64", label: "Berlin" }] }));
|
|
146
|
+
const first = await warnings(() => wikidata.searchCandidates(search.fetcher, "Berlin-uncached", "en", UA));
|
|
147
|
+
assert.deepEqual(first.result, []);
|
|
148
|
+
assert.match(String(first.warned[0][0]), /www\.wikidata\.org.*HTTP 502/);
|
|
149
|
+
assert.deepEqual(await wikidata.searchCandidates(search.fetcher, "Berlin-uncached", "en", UA), [{ id: "Q64", label: "Berlin", description: "" }]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("HTTP 429 defers the job until Retry-After instead of failing it", async () => {
|
|
153
|
+
const sparql = await warnings(() => wikidata.entityDetails(fakeFetch(new Response("slow down", { status: 429, headers: { "retry-after": "120" } })).fetcher, ["Q1"], "en", UA));
|
|
154
|
+
assert.ok(sparql.error instanceof wikidata.WikidataBusyError);
|
|
155
|
+
assert.equal((sparql.error as InstanceType<typeof wikidata.WikidataBusyError>).retryAfterMs, 120_000);
|
|
156
|
+
assert.deepEqual(sparql.warned[0][1], { retryAfter: "120" });
|
|
157
|
+
const search = await warnings(() => wikidata.searchCandidates(fakeFetch(new Response("", { status: 429 })).fetcher, "Paris-429", "en", UA));
|
|
158
|
+
assert.ok(search.error instanceof wikidata.WikidataBusyError);
|
|
159
|
+
assert.equal((search.error as InstanceType<typeof wikidata.WikidataBusyError>).retryAfterMs, 60_000, "no Retry-After: a minute");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("api.php calls send maxlag=5, and a maxlag answer defers like a 429", async () => {
|
|
163
|
+
const lagged = json({ error: { code: "maxlag", info: "Waiting for a database server: 6 seconds lagged." } }, { headers: { "retry-after": "5" } });
|
|
164
|
+
const { fetcher, calls } = fakeFetch(lagged);
|
|
165
|
+
const { error, warned } = await warnings(() => wikidata.searchCandidates(fetcher, "Paris-lag", "fr", UA));
|
|
166
|
+
const url = new URL(calls[0].url);
|
|
167
|
+
assert.equal(url.host, wikidata.WIKIDATA_HOST);
|
|
168
|
+
assert.equal(url.searchParams.get("maxlag"), "5");
|
|
169
|
+
assert.equal(url.searchParams.get("action"), "wbsearchentities");
|
|
170
|
+
assert.equal(url.searchParams.get("language"), "fr");
|
|
171
|
+
assert.ok(error instanceof wikidata.WikidataBusyError);
|
|
172
|
+
assert.equal((error as InstanceType<typeof wikidata.WikidataBusyError>).retryAfterMs, 5_000);
|
|
173
|
+
assert.match(String(warned[0][0]), /maxlag/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("retryAfterMs reads seconds or an HTTP date, within 5 seconds to an hour", () => {
|
|
177
|
+
const now = Date.parse("2026-10-07T12:00:00Z");
|
|
178
|
+
assert.equal(wikidata.retryAfterMs("30", now), 30_000);
|
|
179
|
+
assert.equal(wikidata.retryAfterMs("Wed, 07 Oct 2026 12:02:00 GMT", now), 120_000);
|
|
180
|
+
assert.equal(wikidata.retryAfterMs(null, now), 60_000);
|
|
181
|
+
assert.equal(wikidata.retryAfterMs("soon", now), 60_000);
|
|
182
|
+
assert.equal(wikidata.retryAfterMs("0", now), 5_000);
|
|
183
|
+
assert.equal(wikidata.retryAfterMs("86400", now), 3_600_000);
|
|
184
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wikidata lookups for entity grounding: wbsearchentities (api.php) for real
|
|
3
|
+
* candidates, then one Wikidata Query Service (SPARQL) request for P31
|
|
4
|
+
* (instance of), P856 (official website) and the Wikipedia sitelink. Public
|
|
5
|
+
* APIs, no key. Search results are cached per isolate so repeated names (and
|
|
6
|
+
* bulk runs) cost no extra subrequests.
|
|
7
|
+
*
|
|
8
|
+
* Etiquette (https://www.mediawiki.org/wiki/API:Etiquette): one request at a
|
|
9
|
+
* time, maxlag=5 on api.php, and a 429 or maxlag answer defers the job until
|
|
10
|
+
* Retry-After instead of failing it.
|
|
11
|
+
*/
|
|
12
|
+
import { type Candidate, type WikidataDetails, detailsQuery, parseSearch, parseSparqlDetails } from "./logic.js";
|
|
13
|
+
|
|
14
|
+
export const WIKIDATA_HOST = "www.wikidata.org";
|
|
15
|
+
export const WIKIDATA_QUERY_HOST = "query.wikidata.org";
|
|
16
|
+
const API = `https://${WIKIDATA_HOST}/w/api.php`;
|
|
17
|
+
const SPARQL = `https://${WIKIDATA_QUERY_HOST}/sparql`;
|
|
18
|
+
const CACHE_MAX = 500;
|
|
19
|
+
const searchCache = new Map<string, Candidate[]>();
|
|
20
|
+
|
|
21
|
+
type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
22
|
+
|
|
23
|
+
/** Wikidata asked us to slow down (HTTP 429, or api.php's maxlag): try the job again after `retryAfterMs`. */
|
|
24
|
+
export class WikidataBusyError extends Error {
|
|
25
|
+
readonly retryAfterMs: number;
|
|
26
|
+
constructor(retryAfterMs: number) {
|
|
27
|
+
super(`Wikidata asked to slow down. Retrying in ${Math.ceil(retryAfterMs / 1000)} seconds.`);
|
|
28
|
+
this.name = "WikidataBusyError";
|
|
29
|
+
this.retryAfterMs = retryAfterMs;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A Retry-After header (seconds or an HTTP date) in ms, kept between 5 seconds and 1 hour; 60 seconds when missing or unreadable. */
|
|
34
|
+
export function retryAfterMs(header: string | null, now = Date.now()): number {
|
|
35
|
+
const value = header?.trim() ?? "";
|
|
36
|
+
let ms = /^\d+$/.test(value) ? Number(value) * 1000 : value ? Date.parse(value) - now : Number.NaN;
|
|
37
|
+
if (!Number.isFinite(ms)) ms = 60_000;
|
|
38
|
+
return Math.min(Math.max(ms, 5_000), 3_600_000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getJson(fetcher: Fetch, url: string, userAgent: string, accept: string): Promise<unknown> {
|
|
42
|
+
let response: Response;
|
|
43
|
+
try {
|
|
44
|
+
response = await fetcher(url, { headers: { "user-agent": userAgent, accept }, signal: AbortSignal.timeout(10_000) });
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const retryAfter = response.headers.get("retry-after");
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
console.warn(`[coywolf-pack] Wikidata (${new URL(url).host}) answered HTTP ${response.status}`, { retryAfter });
|
|
51
|
+
if (response.status === 429) throw new WikidataBusyError(retryAfterMs(retryAfter));
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
let body: unknown;
|
|
55
|
+
try {
|
|
56
|
+
body = await response.json();
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if ((body as { error?: { code?: unknown } })?.error?.code === "maxlag") {
|
|
61
|
+
console.warn(`[coywolf-pack] Wikidata (${new URL(url).host}) is lagged (maxlag)`, { retryAfter });
|
|
62
|
+
throw new WikidataBusyError(retryAfterMs(retryAfter));
|
|
63
|
+
}
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function searchCandidates(fetcher: Fetch, name: string, language: string, userAgent: string): Promise<Candidate[]> {
|
|
68
|
+
const key = `${language}|${name.toLowerCase()}`;
|
|
69
|
+
const hit = searchCache.get(key);
|
|
70
|
+
if (hit) return hit;
|
|
71
|
+
const params = { action: "wbsearchentities", format: "json", maxlag: "5", language, uselang: language, type: "item", limit: "5", search: name };
|
|
72
|
+
const body = await getJson(fetcher, `${API}?${new URLSearchParams(params)}`, userAgent, "application/json");
|
|
73
|
+
if (body === null) return []; // Don't cache failures.
|
|
74
|
+
const candidates = parseSearch(body);
|
|
75
|
+
if (searchCache.size >= CACHE_MAX) searchCache.delete(searchCache.keys().next().value as string);
|
|
76
|
+
searchCache.set(key, candidates);
|
|
77
|
+
return candidates;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Details for up to 50 items in one SPARQL request; null when Wikidata didn't answer (the caller retries later). */
|
|
81
|
+
export async function entityDetails(fetcher: Fetch, qids: string[], language: string, userAgent: string): Promise<Record<string, WikidataDetails> | null> {
|
|
82
|
+
const ids = [...new Set(qids.filter((q) => /^Q\d+$/.test(q)))].slice(0, 50);
|
|
83
|
+
if (!ids.length) return {};
|
|
84
|
+
const url = `${SPARQL}?${new URLSearchParams({ query: detailsQuery(ids, language), format: "json" })}`;
|
|
85
|
+
const body = await getJson(fetcher, url, userAgent, "application/sparql-results+json");
|
|
86
|
+
return body === null ? null : parseSparqlDetails(body, language);
|
|
87
|
+
}
|