@coywolf/emdash 0.0.0-stage → 0.35.14
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 +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -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.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/routes.ts +359 -0
- package/src/ai/store.ts +301 -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.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -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.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.ts +60 -0
- package/src/files/export.ts +44 -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.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.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/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.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.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.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.ts +362 -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.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
package/src/ai/logic.ts
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Enrichment: pure logic (no imports, no I/O) so it can be unit tested
|
|
3
|
+
* with `node --test`. Model-output parsing and validation, Wikidata grounding
|
|
4
|
+
* filters, Portable Text → plain text, and queue batching.
|
|
5
|
+
*
|
|
6
|
+
* Ported from Coywolf SEO for WordPress (class-coywolf-seo-ai.php and
|
|
7
|
+
* class-coywolf-seo-image-ai.php): the model extracts entity MENTIONS only
|
|
8
|
+
* (never identifiers); real Wikidata candidates are looked up
|
|
9
|
+
* deterministically; the model may only choose among those candidates; and
|
|
10
|
+
* each chosen item's P31 (instance of) claims are checked against the
|
|
11
|
+
* expected type.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const ENTITY_TYPES = ["Person", "Organization", "Place", "Thing"] as const;
|
|
15
|
+
export type EntityType = (typeof ENTITY_TYPES)[number];
|
|
16
|
+
|
|
17
|
+
export const MAX_ENTITIES = 12;
|
|
18
|
+
export const DESCRIPTION_MAX = 155;
|
|
19
|
+
export const ALT_MAX = 250;
|
|
20
|
+
export const CAPTION_MAX = 300;
|
|
21
|
+
|
|
22
|
+
/** Q-number of a Wikimedia disambiguation page and of "human". */
|
|
23
|
+
const DISAMBIGUATION = "Q4167410";
|
|
24
|
+
const HUMAN = "Q5";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Common `instance of` classes for creative works and publications. An
|
|
28
|
+
* entity the article calls a Person, Organization, or Place must not resolve
|
|
29
|
+
* to one of these (e.g. the band's album instead of the band).
|
|
30
|
+
*/
|
|
31
|
+
const CREATIVE_WORKS = new Set([
|
|
32
|
+
"Q7725634", // literary work
|
|
33
|
+
"Q571", // book
|
|
34
|
+
"Q47461344", // written work
|
|
35
|
+
"Q11424", // film
|
|
36
|
+
"Q5398426", // television series
|
|
37
|
+
"Q21191270", // television series episode
|
|
38
|
+
"Q482994", // album
|
|
39
|
+
"Q134556", // single
|
|
40
|
+
"Q7366", // song
|
|
41
|
+
"Q105543609", // musical work/composition
|
|
42
|
+
"Q7889", // video game
|
|
43
|
+
"Q13442814", // scholarly article
|
|
44
|
+
"Q191067", // article
|
|
45
|
+
"Q3305213", // painting
|
|
46
|
+
"Q838948", // work of art
|
|
47
|
+
"Q17537576", // creative work
|
|
48
|
+
"Q87167", // manuscript
|
|
49
|
+
"Q1004", // comics
|
|
50
|
+
"Q25379", // play
|
|
51
|
+
"Q24862", // short film
|
|
52
|
+
"Q506240", // television film
|
|
53
|
+
"Q63952888", // anime television series
|
|
54
|
+
"Q4167836", // Wikimedia category
|
|
55
|
+
"Q11266439", // Wikimedia template
|
|
56
|
+
"Q13406463", // Wikimedia list article
|
|
57
|
+
"Q17633526", // Wikinews article
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** Geographic classes an Organization must not resolve to. */
|
|
61
|
+
const GEOGRAPHIC = new Set([
|
|
62
|
+
"Q515", // city
|
|
63
|
+
"Q1549591", // big city
|
|
64
|
+
"Q5119", // capital
|
|
65
|
+
"Q6256", // country
|
|
66
|
+
"Q3624078", // sovereign state
|
|
67
|
+
"Q35657", // U.S. state
|
|
68
|
+
"Q486972", // human settlement
|
|
69
|
+
"Q532", // village
|
|
70
|
+
"Q3957", // town
|
|
71
|
+
"Q23442", // island
|
|
72
|
+
"Q8502", // mountain
|
|
73
|
+
"Q4022", // river
|
|
74
|
+
"Q23397", // lake
|
|
75
|
+
"Q1620908", // historical region
|
|
76
|
+
"Q82794", // geographic region
|
|
77
|
+
"Q5107", // continent
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/** Lowercase, strip diacritics and punctuation, collapse whitespace: "Café-Müller, Inc." → "cafe muller inc". */
|
|
81
|
+
export function normalizeName(value: string): string {
|
|
82
|
+
return value
|
|
83
|
+
.normalize("NFKD")
|
|
84
|
+
.replace(/\p{M}+/gu, "")
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(/[\p{P}\p{S}]+/gu, " ")
|
|
87
|
+
.replace(/\s+/g, " ")
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface Mention {
|
|
92
|
+
surface: string;
|
|
93
|
+
name: string;
|
|
94
|
+
type: EntityType;
|
|
95
|
+
description: string;
|
|
96
|
+
primary: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface Candidate {
|
|
100
|
+
id: string;
|
|
101
|
+
label: string;
|
|
102
|
+
description: string;
|
|
103
|
+
/** Aliases and the matched text wbsearchentities reports, for the name check. */
|
|
104
|
+
aliases?: string[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface GroundedMention extends Mention {
|
|
108
|
+
qid: string;
|
|
109
|
+
candidates: Candidate[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface WikidataDetails {
|
|
113
|
+
p31: string[];
|
|
114
|
+
wikipedia: string;
|
|
115
|
+
website: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A stored, verified entity. */
|
|
119
|
+
export interface Entity {
|
|
120
|
+
name: string;
|
|
121
|
+
type: EntityType;
|
|
122
|
+
description: string;
|
|
123
|
+
qid: string;
|
|
124
|
+
wikipedia: string;
|
|
125
|
+
website: string;
|
|
126
|
+
primary: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ImageText {
|
|
130
|
+
alt: string;
|
|
131
|
+
caption: string;
|
|
132
|
+
title: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Model output ─────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/** Decode a model response that should be bare JSON, tolerating code fences and leading prose. */
|
|
138
|
+
export function decodeJson(text: unknown): unknown {
|
|
139
|
+
if (text !== null && typeof text === "object") return text; // Workers AI may already return parsed JSON.
|
|
140
|
+
let s = String(text ?? "").trim();
|
|
141
|
+
s = s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "");
|
|
142
|
+
const start = s.search(/[[{]/);
|
|
143
|
+
if (start < 0) return null;
|
|
144
|
+
s = s.slice(start);
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(s);
|
|
147
|
+
} catch {
|
|
148
|
+
// Trailing prose after the JSON: cut at the last matching closer.
|
|
149
|
+
const closer = s[0] === "[" ? "]" : "}";
|
|
150
|
+
const end = s.lastIndexOf(closer);
|
|
151
|
+
if (end <= 0) return null;
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(s.slice(0, end + 1));
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Strip tags and control characters, collapse whitespace, cap length (on code points). */
|
|
161
|
+
export function cleanText(value: unknown, max = 500): string {
|
|
162
|
+
if (typeof value !== "string" && typeof value !== "number") return "";
|
|
163
|
+
let s = String(value)
|
|
164
|
+
.replace(/<[^>]*>/g, " ")
|
|
165
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control characters is the point.
|
|
166
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
167
|
+
.replace(/\s+/gu, " ")
|
|
168
|
+
.trim();
|
|
169
|
+
const chars = [...s];
|
|
170
|
+
if (chars.length > max) s = chars.slice(0, max).join("").trim();
|
|
171
|
+
return s;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Parse and sanitize the entity-extraction response (a JSON array of mentions). */
|
|
175
|
+
export function parseMentions(text: unknown): Mention[] {
|
|
176
|
+
const rows = decodeJson(text);
|
|
177
|
+
const list = Array.isArray(rows) ? rows : rows && typeof rows === "object" && Array.isArray((rows as { entities?: unknown }).entities) ? (rows as { entities: unknown[] }).entities : null;
|
|
178
|
+
if (!list) return [];
|
|
179
|
+
const out: Mention[] = [];
|
|
180
|
+
const seen = new Set<string>();
|
|
181
|
+
for (const row of list) {
|
|
182
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
183
|
+
const r = row as Record<string, unknown>;
|
|
184
|
+
const name = cleanText(r.name, 120);
|
|
185
|
+
if (!name || !r.type) continue;
|
|
186
|
+
const key = name.toLowerCase();
|
|
187
|
+
if (seen.has(key)) continue;
|
|
188
|
+
seen.add(key);
|
|
189
|
+
const type = (ENTITY_TYPES as readonly string[]).includes(String(r.type)) ? (String(r.type) as EntityType) : "Thing";
|
|
190
|
+
out.push({
|
|
191
|
+
surface: cleanText(r.surface, 120),
|
|
192
|
+
name,
|
|
193
|
+
type,
|
|
194
|
+
description: cleanText(r.description, 200),
|
|
195
|
+
primary: r.primary === true || r.primary === "true",
|
|
196
|
+
});
|
|
197
|
+
if (out.length >= MAX_ENTITIES) break;
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Parse the disambiguation response: { "Entity name": "Q123" | null }. */
|
|
203
|
+
export function parseChoices(text: unknown): Record<string, string | null> {
|
|
204
|
+
const decoded = decodeJson(text);
|
|
205
|
+
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return {};
|
|
206
|
+
const out: Record<string, string | null> = {};
|
|
207
|
+
for (const [name, value] of Object.entries(decoded as Record<string, unknown>)) {
|
|
208
|
+
out[name] = typeof value === "string" && /^Q\d+$/i.test(value.trim()) ? value.trim().toUpperCase() : null;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Normalize a generated meta description: plain single line, no wrapping quotes, ≤ max chars cut on a word. */
|
|
214
|
+
export function cleanDescription(text: unknown, max = DESCRIPTION_MAX): string {
|
|
215
|
+
let s = cleanText(typeof text === "string" ? text : "", 2000);
|
|
216
|
+
const unquote = (v: string) => v.replace(/^["'“‘]+|["'”’]+$/gu, "").trim();
|
|
217
|
+
s = unquote(unquote(s).replace(/^(meta description|description)\s*:\s*/i, ""));
|
|
218
|
+
const chars = [...s];
|
|
219
|
+
if (chars.length <= max) return s;
|
|
220
|
+
const cut = chars.slice(0, max - 1).join("");
|
|
221
|
+
const space = cut.lastIndexOf(" ");
|
|
222
|
+
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:.–—-]+$/u, "")}…`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Parse the image-text response ({ alt_text, title, caption, description }). Throws when nothing usable came back. */
|
|
226
|
+
export function parseImageText(text: unknown): ImageText {
|
|
227
|
+
if (typeof text === "string" && !text.trim()) {
|
|
228
|
+
throw new Error("The AI service returned an empty response (it may have run out of output tokens). Try again, or pick a different model.");
|
|
229
|
+
}
|
|
230
|
+
const fields = decodeJson(text);
|
|
231
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
|
|
232
|
+
throw new Error("The AI service returned a response that could not be parsed. Try again.");
|
|
233
|
+
}
|
|
234
|
+
const f = fields as Record<string, unknown>;
|
|
235
|
+
const alt = cleanText(f.alt_text ?? f.alt, ALT_MAX).replace(/^(an? )?(image|photo|picture|graphic) of /i, "");
|
|
236
|
+
const result: ImageText = {
|
|
237
|
+
alt: alt ? alt[0].toUpperCase() + alt.slice(1) : "",
|
|
238
|
+
caption: cleanText(f.caption, CAPTION_MAX),
|
|
239
|
+
title: cleanText(f.title, 120),
|
|
240
|
+
};
|
|
241
|
+
if (!result.alt && !result.title) throw new Error("The AI service did not return usable image text. Try again.");
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Wikidata grounding ───────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/** Parse a wbsearchentities response into candidates. */
|
|
248
|
+
export function parseSearch(body: unknown): Candidate[] {
|
|
249
|
+
const search = (body as { search?: unknown })?.search;
|
|
250
|
+
if (!Array.isArray(search)) return [];
|
|
251
|
+
const out: Candidate[] = [];
|
|
252
|
+
for (const hit of search) {
|
|
253
|
+
const id = (hit as { id?: unknown })?.id;
|
|
254
|
+
if (typeof id !== "string" || !/^Q\d+$/.test(id)) continue;
|
|
255
|
+
const h = hit as { label?: unknown; description?: unknown; aliases?: unknown; match?: { text?: unknown } };
|
|
256
|
+
const aliases = [...(Array.isArray(h.aliases) ? h.aliases : []), h.match?.text]
|
|
257
|
+
.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
|
|
258
|
+
.map((a) => cleanText(a, 200));
|
|
259
|
+
out.push({
|
|
260
|
+
id,
|
|
261
|
+
label: cleanText(h.label, 200),
|
|
262
|
+
description: cleanText(h.description, 300),
|
|
263
|
+
...(aliases.length ? { aliases: [...new Set(aliases)] } : {}),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The Wikipedia article URL for a sitelink title. */
|
|
270
|
+
export function wikipediaUrl(language: string, title: string): string {
|
|
271
|
+
return `https://${language}.wikipedia.org/wiki/${encodeURIComponent(title.replace(/ /g, "_")).replace(/%2F/g, "/").replace(/%3A/g, ":")}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* One Wikidata Query Service request for what verification needs: each item's
|
|
276
|
+
* P31 (instance of) and P856 (official website) values, deprecated statements
|
|
277
|
+
* excluded, and its `<language>wiki` sitelink title. Only Q-ids get in; items
|
|
278
|
+
* that don't exist (or are redirects) return no rows. The empty last branch
|
|
279
|
+
* keeps an item with none of these in the results.
|
|
280
|
+
*/
|
|
281
|
+
export function detailsQuery(qids: string[], language: string): string {
|
|
282
|
+
const ids = [...new Set(qids.filter((q) => /^Q\d+$/.test(q)))];
|
|
283
|
+
const wiki = /^[a-z]{2,3}$/.test(language) ? `https://${language}.wikipedia.org/` : null;
|
|
284
|
+
return [
|
|
285
|
+
"SELECT ?item ?class ?website ?rank ?title WHERE {",
|
|
286
|
+
` VALUES ?item { ${ids.map((q) => `wd:${q}`).join(" ")} }`,
|
|
287
|
+
" ?item wikibase:sitelinks [] .",
|
|
288
|
+
" { ?item p:P31 ?s . ?s ps:P31 ?class ; wikibase:rank ?r . FILTER(?r != wikibase:DeprecatedRank) }",
|
|
289
|
+
" UNION { ?item p:P856 ?s . ?s ps:P856 ?website ; wikibase:rank ?rank . FILTER(?rank != wikibase:DeprecatedRank) }",
|
|
290
|
+
...(wiki ? [` UNION { ?article schema:about ?item ; schema:isPartOf <${wiki}> ; schema:name ?title . }`] : []),
|
|
291
|
+
" UNION {}",
|
|
292
|
+
"}",
|
|
293
|
+
].join("\n");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const ENTITY_IRI = "http://www.wikidata.org/entity/";
|
|
297
|
+
const entityId = (iri: unknown) => (typeof iri === "string" && iri.startsWith(ENTITY_IRI) && /^Q\d+$/.test(iri.slice(ENTITY_IRI.length)) ? iri.slice(ENTITY_IRI.length) : "");
|
|
298
|
+
|
|
299
|
+
const PREFERRED_RANK = "http://wikiba.se/ontology#PreferredRank";
|
|
300
|
+
/** Sort key for choosing one P856 among several (rows have no order): preferred rank, then https over http, then the smallest URL. */
|
|
301
|
+
const websiteKey = (url: string, rank: string) => `${rank === PREFERRED_RANK ? 0 : 1}${/^https:/i.test(url) ? 0 : 1}${url}`;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Parse the detailsQuery results (SPARQL JSON): P31 values, one http(s) P856
|
|
305
|
+
* (preferred rank first, then https, then the lexicographically smallest, so
|
|
306
|
+
* the choice doesn't depend on row order), and the Wikipedia sitelink. null
|
|
307
|
+
* when the body isn't a SPARQL result.
|
|
308
|
+
*/
|
|
309
|
+
export function parseSparqlDetails(body: unknown, language: string): Record<string, WikidataDetails> | null {
|
|
310
|
+
const rows = (body as { results?: { bindings?: unknown } })?.results?.bindings;
|
|
311
|
+
if (!Array.isArray(rows)) return null;
|
|
312
|
+
const out: Record<string, WikidataDetails> = {};
|
|
313
|
+
const websiteKeys = new Map<string, string>();
|
|
314
|
+
for (const row of rows) {
|
|
315
|
+
const value = (key: string) => {
|
|
316
|
+
const v = (row as Record<string, { value?: unknown } | undefined>)?.[key]?.value;
|
|
317
|
+
return typeof v === "string" ? v : "";
|
|
318
|
+
};
|
|
319
|
+
const qid = entityId(value("item"));
|
|
320
|
+
if (!qid) continue;
|
|
321
|
+
const details = (out[qid] ??= { p31: [], wikipedia: "", website: "" });
|
|
322
|
+
const instanceOf = entityId(value("class"));
|
|
323
|
+
if (instanceOf && !details.p31.includes(instanceOf)) details.p31.push(instanceOf);
|
|
324
|
+
const website = value("website");
|
|
325
|
+
if (/^https?:\/\//i.test(website)) {
|
|
326
|
+
const key = websiteKey(website, value("rank"));
|
|
327
|
+
const current = websiteKeys.get(qid);
|
|
328
|
+
if (current === undefined || key < current) {
|
|
329
|
+
websiteKeys.set(qid, key);
|
|
330
|
+
details.website = website;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const title = value("title");
|
|
334
|
+
if (!details.wikipedia && title) details.wikipedia = wikipediaUrl(language, title);
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Whether a candidate's label or one of its aliases is the mention's name (or its surface form), after normalization. */
|
|
340
|
+
export function nameMatches(mention: Pick<Mention, "name" | "surface">, candidate: Candidate): boolean {
|
|
341
|
+
const wanted = new Set([normalizeName(mention.name), normalizeName(mention.surface ?? "")].filter(Boolean));
|
|
342
|
+
return [candidate.label, ...(candidate.aliases ?? [])].some((text) => wanted.has(normalizeName(text)));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Stage 2: keep only candidates whose label or alias matches the mention's
|
|
347
|
+
* name (search hits on a description or a partial word don't count). One
|
|
348
|
+
* match resolves immediately; several go to disambiguation; none drops the
|
|
349
|
+
* mention.
|
|
350
|
+
*/
|
|
351
|
+
export function attachCandidates(mentions: Mention[], candidates: Candidate[][]): { mentions: GroundedMention[]; ambiguous: number[] } {
|
|
352
|
+
const ambiguous: number[] = [];
|
|
353
|
+
const grounded = mentions.map((m, i) => {
|
|
354
|
+
const list = (candidates[i] ?? []).filter((c) => nameMatches(m, c));
|
|
355
|
+
if (list.length > 1) ambiguous.push(i);
|
|
356
|
+
return { ...m, candidates: list, qid: list.length === 1 ? list[0].id : "" };
|
|
357
|
+
});
|
|
358
|
+
return { mentions: grounded, ambiguous };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Stage 3: trust a choice only when it is one of the real candidates. */
|
|
362
|
+
export function applyChoices(mentions: GroundedMention[], ambiguous: number[], choices: Record<string, string | null>): GroundedMention[] {
|
|
363
|
+
const out = mentions.map((m) => ({ ...m }));
|
|
364
|
+
for (const i of ambiguous) {
|
|
365
|
+
const mention = out[i];
|
|
366
|
+
if (!mention) continue;
|
|
367
|
+
const chosen = choices[mention.name];
|
|
368
|
+
if (!chosen) continue;
|
|
369
|
+
if (mention.candidates.some((c) => c.id === chosen)) mention.qid = chosen;
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Whether an item's P31 classes fit the type the article gave the entity. */
|
|
375
|
+
export function typeCompatible(type: EntityType, p31: string[]): boolean {
|
|
376
|
+
if (p31.includes(DISAMBIGUATION)) return false;
|
|
377
|
+
const human = p31.includes(HUMAN);
|
|
378
|
+
const creative = p31.some((q) => CREATIVE_WORKS.has(q));
|
|
379
|
+
switch (type) {
|
|
380
|
+
case "Person":
|
|
381
|
+
return human; // An item with no P31 can't be confirmed as a person.
|
|
382
|
+
case "Organization":
|
|
383
|
+
return p31.length > 0 && !human && !creative && !p31.some((q) => GEOGRAPHIC.has(q));
|
|
384
|
+
case "Place":
|
|
385
|
+
return p31.length > 0 && !human && !creative;
|
|
386
|
+
default:
|
|
387
|
+
return !human && !p31.some((q) => q === "Q4167836" || q === "Q11266439" || q === "Q13406463");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Stage 4: drop unresolved mentions, items Wikidata didn't return, and type
|
|
393
|
+
* mismatches. The entity's name is Wikidata's label (the canonical name),
|
|
394
|
+
* falling back to the model's name.
|
|
395
|
+
*/
|
|
396
|
+
export function verifyEntities(mentions: GroundedMention[], details: Record<string, WikidataDetails>): Entity[] {
|
|
397
|
+
const out: Entity[] = [];
|
|
398
|
+
const seen = new Set<string>();
|
|
399
|
+
for (const m of mentions) {
|
|
400
|
+
if (!m.qid || seen.has(m.qid)) continue;
|
|
401
|
+
const info = details[m.qid];
|
|
402
|
+
if (!info || !typeCompatible(m.type, info.p31)) continue;
|
|
403
|
+
seen.add(m.qid);
|
|
404
|
+
const label = m.candidates.find((c) => c.id === m.qid)?.label;
|
|
405
|
+
out.push({
|
|
406
|
+
name: label || m.name,
|
|
407
|
+
type: m.type,
|
|
408
|
+
description: m.description,
|
|
409
|
+
qid: m.qid,
|
|
410
|
+
wikipedia: info.wikipedia,
|
|
411
|
+
website: info.website,
|
|
412
|
+
primary: m.primary,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Schema.org nodes for stored entities: { about, mentions }. */
|
|
419
|
+
export function entityNodes(entities: Entity[] | undefined | null): { about: Record<string, unknown>[]; mentions: Record<string, unknown>[] } {
|
|
420
|
+
const about: Record<string, unknown>[] = [];
|
|
421
|
+
const mentions: Record<string, unknown>[] = [];
|
|
422
|
+
for (const e of entities ?? []) {
|
|
423
|
+
if (!e?.name || !e.qid || !/^Q\d+$/.test(e.qid)) continue;
|
|
424
|
+
const sameAs = [`https://www.wikidata.org/wiki/${e.qid}`];
|
|
425
|
+
if (e.wikipedia) sameAs.push(e.wikipedia);
|
|
426
|
+
if (e.website) sameAs.push(e.website);
|
|
427
|
+
const node: Record<string, unknown> = {
|
|
428
|
+
"@type": (ENTITY_TYPES as readonly string[]).includes(e.type) ? e.type : "Thing",
|
|
429
|
+
name: e.name,
|
|
430
|
+
sameAs: sameAs.length > 1 ? sameAs : sameAs[0],
|
|
431
|
+
};
|
|
432
|
+
if (e.description) node.description = e.description;
|
|
433
|
+
(e.primary ? about : mentions).push(node);
|
|
434
|
+
}
|
|
435
|
+
return { about, mentions };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Content → plain text ─────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
/** Plain text from a Portable Text value (or any nested structure holding blocks), paragraphs separated by blank lines. */
|
|
441
|
+
export function portableTextToPlain(value: unknown, depth = 0): string {
|
|
442
|
+
if (depth > 8 || value == null) return "";
|
|
443
|
+
if (typeof value === "string") return "";
|
|
444
|
+
if (Array.isArray(value)) {
|
|
445
|
+
return value
|
|
446
|
+
.map((v) => portableTextToPlain(v, depth + 1))
|
|
447
|
+
.filter(Boolean)
|
|
448
|
+
.join("\n\n");
|
|
449
|
+
}
|
|
450
|
+
if (typeof value !== "object") return "";
|
|
451
|
+
const node = value as Record<string, unknown>;
|
|
452
|
+
if (node._type === "block" && Array.isArray(node.children)) {
|
|
453
|
+
return node.children
|
|
454
|
+
.map((c) => (c && typeof c === "object" && typeof (c as { text?: unknown }).text === "string" ? (c as { text: string }).text : ""))
|
|
455
|
+
.join("")
|
|
456
|
+
.trim();
|
|
457
|
+
}
|
|
458
|
+
// Custom blocks: look inside nested arrays (columns, callouts…) and plain text props.
|
|
459
|
+
const parts: string[] = [];
|
|
460
|
+
for (const [key, child] of Object.entries(node)) {
|
|
461
|
+
if (key.startsWith("_") || key === "markDefs") continue;
|
|
462
|
+
if (Array.isArray(child) || (child && typeof child === "object")) {
|
|
463
|
+
const text = portableTextToPlain(child, depth + 1);
|
|
464
|
+
if (text) parts.push(text);
|
|
465
|
+
} else if ((key === "text" || key === "caption" || key === "quote") && typeof child === "string" && child.trim()) {
|
|
466
|
+
parts.push(child.trim());
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return parts.join("\n\n");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** A bounded plain-text rendition of an entry: its text-like fields in order. */
|
|
473
|
+
export function entryPlainText(data: Record<string, unknown>, fields: Array<{ slug: string; type: string }>, limit = 24000): string {
|
|
474
|
+
const parts: string[] = [];
|
|
475
|
+
for (const field of fields) {
|
|
476
|
+
const value = data[field.slug];
|
|
477
|
+
if (field.type === "portableText" || field.type === "blocks") parts.push(portableTextToPlain(value));
|
|
478
|
+
else if (field.type === "text" && typeof value === "string") parts.push(value.replace(/<[^>]*>/g, " "));
|
|
479
|
+
}
|
|
480
|
+
const text = parts
|
|
481
|
+
.map((p) => p.trim())
|
|
482
|
+
.filter(Boolean)
|
|
483
|
+
.join("\n\n")
|
|
484
|
+
.replace(/[ \t]+/g, " ");
|
|
485
|
+
return [...text].slice(0, limit).join("");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Queue ────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
export interface QueueJob {
|
|
491
|
+
kind: "entry" | "media";
|
|
492
|
+
collection?: string;
|
|
493
|
+
entryId?: string;
|
|
494
|
+
mediaId?: string;
|
|
495
|
+
/** Epoch ms when the job may run (debounce / backoff). */
|
|
496
|
+
due: number;
|
|
497
|
+
attempts: number;
|
|
498
|
+
/** Times in a row Wikidata's Retry-After pushed the job back; reset when an attempt fails. */
|
|
499
|
+
deferrals?: number;
|
|
500
|
+
force?: boolean;
|
|
501
|
+
enqueuedAt: number;
|
|
502
|
+
lastError?: string;
|
|
503
|
+
/** Model outputs already paid for, so a retry resumes after them. Valid only for the same text hash. */
|
|
504
|
+
cache?: StageCache;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export interface StageCache {
|
|
508
|
+
hash: string;
|
|
509
|
+
mentions?: Mention[];
|
|
510
|
+
choices?: Record<string, string | null>;
|
|
511
|
+
description?: string;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export const MAX_ATTEMPTS = 3;
|
|
515
|
+
/** Wikidata "slow down" deferrals a job gets before one counts as a failed attempt. */
|
|
516
|
+
export const MAX_DEFERRALS = 10;
|
|
517
|
+
|
|
518
|
+
export function queueId(job: Pick<QueueJob, "kind" | "collection" | "entryId" | "mediaId">): string {
|
|
519
|
+
return job.kind === "media" ? `media:${job.mediaId}` : `entry:${job.collection}:${job.entryId}`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Pick the jobs to run this tick: due jobs, at most `perTick`, taking entries
|
|
524
|
+
* and images in turns (so a backlog of one kind can't starve the other), each
|
|
525
|
+
* kind in due order, and never more model calls than the day's remaining
|
|
526
|
+
* allowance (a job costs up to `callsPerJob` calls) or outbound requests than
|
|
527
|
+
* `subrequests` (a job costs up to `subrequestsPerJob`). When a kind's next
|
|
528
|
+
* job doesn't fit, that kind stops (its later jobs don't jump the line).
|
|
529
|
+
*/
|
|
530
|
+
export function planBatch(
|
|
531
|
+
jobs: Array<{ id: string; data: QueueJob }>,
|
|
532
|
+
opts: {
|
|
533
|
+
now: number;
|
|
534
|
+
perTick: number;
|
|
535
|
+
remainingCalls: number;
|
|
536
|
+
callsPerJob: (job: QueueJob) => number;
|
|
537
|
+
subrequests?: number;
|
|
538
|
+
subrequestsPerJob?: (job: QueueJob) => number;
|
|
539
|
+
},
|
|
540
|
+
): Array<{ id: string; data: QueueJob }> {
|
|
541
|
+
const byDue = (a: { id: string; data: QueueJob }, b: { id: string; data: QueueJob }) => a.data.due - b.data.due || a.id.localeCompare(b.id);
|
|
542
|
+
const lanes = (["entry", "media"] as const)
|
|
543
|
+
.map((kind) => jobs.filter((j) => j.data.kind === kind && j.data.due <= opts.now).sort(byDue))
|
|
544
|
+
.filter((lane) => lane.length)
|
|
545
|
+
.sort((a, b) => byDue(a[0], b[0])); // The lane with the oldest job goes first.
|
|
546
|
+
const out: Array<{ id: string; data: QueueJob }> = [];
|
|
547
|
+
let calls = Math.max(0, opts.remainingCalls);
|
|
548
|
+
let requests = opts.subrequests ?? Number.POSITIVE_INFINITY;
|
|
549
|
+
const open = lanes.map(() => true);
|
|
550
|
+
const next = lanes.map(() => 0);
|
|
551
|
+
while (out.length < opts.perTick && open.some(Boolean)) {
|
|
552
|
+
for (let lane = 0; lane < lanes.length && out.length < opts.perTick; lane++) {
|
|
553
|
+
if (!open[lane]) continue;
|
|
554
|
+
const job = lanes[lane][next[lane]];
|
|
555
|
+
if (!job) {
|
|
556
|
+
open[lane] = false;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const cost = opts.callsPerJob(job.data);
|
|
560
|
+
const reqs = opts.subrequestsPerJob?.(job.data) ?? 0;
|
|
561
|
+
if (cost > calls || reqs > requests) {
|
|
562
|
+
open[lane] = false;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
calls -= cost;
|
|
566
|
+
requests -= reqs;
|
|
567
|
+
next[lane]++;
|
|
568
|
+
out.push(job);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Push a job back for Wikidata's Retry-After without using up an attempt.
|
|
576
|
+
* null once it has been deferred MAX_DEFERRALS times in a row (a lasting 429),
|
|
577
|
+
* when the caller counts it as a failed attempt instead.
|
|
578
|
+
*/
|
|
579
|
+
export function deferJob(job: QueueJob, now: number, retryAfterMs: number): QueueJob | null {
|
|
580
|
+
const deferrals = (job.deferrals ?? 0) + 1;
|
|
581
|
+
if (deferrals > MAX_DEFERRALS) return null;
|
|
582
|
+
return { ...job, deferrals, due: now + retryAfterMs };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Wikimedia User-Agent policy format: client/version (contact) library. The repository is the contact when the site has no URL. */
|
|
586
|
+
export function userAgent(version: string, siteUrl: string | undefined): string {
|
|
587
|
+
return `CoywolfPack/${version} (${siteUrl || "https://github.com/coywolf-llc/coywolf-pack"}) EmDash`;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Retry schedule after a failure: 5, then 20 minutes; null after MAX_ATTEMPTS (3) tries, when the job should be dropped. */
|
|
591
|
+
export function retryAt(job: QueueJob, now: number): number | null {
|
|
592
|
+
const attempts = job.attempts + 1;
|
|
593
|
+
if (attempts >= MAX_ATTEMPTS) return null;
|
|
594
|
+
return now + 5 * 60_000 * 4 ** (attempts - 1);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Merge a re-enqueue into an existing job: push the debounce later, keep attempts reset, keep force if either asked. */
|
|
598
|
+
export function mergeJob(existing: QueueJob | null, next: QueueJob): QueueJob {
|
|
599
|
+
if (!existing) return next;
|
|
600
|
+
return { ...next, force: Boolean(existing.force || next.force), enqueuedAt: Math.min(existing.enqueuedAt, next.enqueuedAt), attempts: 0 };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** UTC day key for the daily call counter. */
|
|
604
|
+
export function dayKey(now: number): string {
|
|
605
|
+
return new Date(now).toISOString().slice(0, 10);
|
|
606
|
+
}
|