@rmdes/indiekit-endpoint-webmention-io 1.0.6 → 1.0.7
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/lib/hcard.js +236 -0
- package/lib/storage/webmentions.js +49 -0
- package/lib/sync.js +62 -2
- package/package.json +1 -1
package/lib/hcard.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* h-card discovery for author photo enrichment
|
|
3
|
+
*
|
|
4
|
+
* When webmention.io returns empty author photos (common with IndieWeb
|
|
5
|
+
* sites that only have h-cards on their homepage, not individual post pages),
|
|
6
|
+
* this module fetches the source domain's homepage and parses the h-card
|
|
7
|
+
* to find the author's photo and URL.
|
|
8
|
+
*
|
|
9
|
+
* Results are cached in MongoDB (7-day TTL) and in-memory (process lifetime)
|
|
10
|
+
* to avoid redundant HTTP requests.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// In-memory cache: domain -> { photoUrl, authorUrl } | null
|
|
14
|
+
const memoryCache = new Map();
|
|
15
|
+
|
|
16
|
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Discover author data (photo + URL) from a domain's homepage h-card
|
|
20
|
+
* @param {string} domain - Domain to check (e.g., "crowdersoup.com")
|
|
21
|
+
* @param {object} [cacheCollection] - MongoDB collection for persistent cache
|
|
22
|
+
* @returns {Promise<{photoUrl: string|null, authorUrl: string|null}>}
|
|
23
|
+
*/
|
|
24
|
+
export async function discoverAuthorData(domain, cacheCollection) {
|
|
25
|
+
if (!domain) return { photoUrl: null, authorUrl: null };
|
|
26
|
+
|
|
27
|
+
// Check in-memory cache
|
|
28
|
+
if (memoryCache.has(domain)) {
|
|
29
|
+
return memoryCache.get(domain);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check MongoDB cache
|
|
33
|
+
if (cacheCollection) {
|
|
34
|
+
try {
|
|
35
|
+
const cached = await cacheCollection.findOne({ domain });
|
|
36
|
+
if (cached && !isExpired(cached.fetchedAt)) {
|
|
37
|
+
const result = {
|
|
38
|
+
photoUrl: cached.photoUrl || null,
|
|
39
|
+
authorUrl: cached.authorUrl || null,
|
|
40
|
+
};
|
|
41
|
+
memoryCache.set(domain, result);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Cache read failure is non-fatal
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Fetch and parse the homepage h-card
|
|
50
|
+
let result = { photoUrl: null, authorUrl: null };
|
|
51
|
+
try {
|
|
52
|
+
result = await fetchHcardData(domain);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.log(
|
|
55
|
+
`[Webmentions] h-card discovery failed for ${domain}: ${error.message}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Store in caches
|
|
60
|
+
memoryCache.set(domain, result);
|
|
61
|
+
|
|
62
|
+
if (cacheCollection) {
|
|
63
|
+
try {
|
|
64
|
+
await cacheCollection.updateOne(
|
|
65
|
+
{ domain },
|
|
66
|
+
{
|
|
67
|
+
$set: {
|
|
68
|
+
domain,
|
|
69
|
+
photoUrl: result.photoUrl,
|
|
70
|
+
authorUrl: result.authorUrl,
|
|
71
|
+
fetchedAt: new Date().toISOString(),
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{ upsert: true },
|
|
75
|
+
);
|
|
76
|
+
} catch {
|
|
77
|
+
// Cache write failure is non-fatal
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fetch a domain's homepage and extract h-card data
|
|
86
|
+
* @param {string} domain
|
|
87
|
+
* @returns {Promise<{photoUrl: string|null, authorUrl: string|null}>}
|
|
88
|
+
*/
|
|
89
|
+
async function fetchHcardData(domain) {
|
|
90
|
+
const baseUrl = `https://${domain}`;
|
|
91
|
+
const response = await fetch(baseUrl, {
|
|
92
|
+
headers: {
|
|
93
|
+
accept: "text/html",
|
|
94
|
+
"user-agent": "Indiekit-Webmention/1.0 (h-card discovery)",
|
|
95
|
+
},
|
|
96
|
+
signal: AbortSignal.timeout(10_000),
|
|
97
|
+
redirect: "follow",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
return { photoUrl: null, authorUrl: null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const contentType = response.headers.get("content-type") || "";
|
|
105
|
+
if (!contentType.includes("text/html")) {
|
|
106
|
+
return { photoUrl: null, authorUrl: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const html = await response.text();
|
|
110
|
+
return parseHcard(html, baseUrl);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse h-card microformat from HTML to extract photo and URL
|
|
115
|
+
* @param {string} html - Page HTML
|
|
116
|
+
* @param {string} baseUrl - Base URL for resolving relative URLs
|
|
117
|
+
* @returns {{photoUrl: string|null, authorUrl: string|null}}
|
|
118
|
+
*/
|
|
119
|
+
export function parseHcard(html, baseUrl) {
|
|
120
|
+
let photoUrl = null;
|
|
121
|
+
let authorUrl = null;
|
|
122
|
+
|
|
123
|
+
// Find u-photo images and u-url links in the page.
|
|
124
|
+
// These are microformat-specific class names that are unlikely to
|
|
125
|
+
// appear outside h-card context.
|
|
126
|
+
photoUrl = findUPhoto(html);
|
|
127
|
+
authorUrl = findUUrl(html);
|
|
128
|
+
|
|
129
|
+
// Resolve relative URLs
|
|
130
|
+
if (photoUrl) {
|
|
131
|
+
photoUrl = resolveUrl(photoUrl, baseUrl);
|
|
132
|
+
}
|
|
133
|
+
if (authorUrl) {
|
|
134
|
+
authorUrl = resolveUrl(authorUrl, baseUrl);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { photoUrl, authorUrl };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Find u-photo value from img elements
|
|
142
|
+
* @param {string} html
|
|
143
|
+
* @returns {string|null}
|
|
144
|
+
*/
|
|
145
|
+
function findUPhoto(html) {
|
|
146
|
+
const imgRegex = /<img\s[^>]*?>/gi;
|
|
147
|
+
let match;
|
|
148
|
+
|
|
149
|
+
while ((match = imgRegex.exec(html)) !== null) {
|
|
150
|
+
const tag = match[0];
|
|
151
|
+
|
|
152
|
+
if (!hasClass(tag, "u-photo")) continue;
|
|
153
|
+
|
|
154
|
+
const src = getAttr(tag, "src");
|
|
155
|
+
if (src) return src;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Find u-url or u-uid value from anchor elements
|
|
163
|
+
* @param {string} html
|
|
164
|
+
* @returns {string|null}
|
|
165
|
+
*/
|
|
166
|
+
function findUUrl(html) {
|
|
167
|
+
const aRegex = /<a\s[^>]*?>/gi;
|
|
168
|
+
let match;
|
|
169
|
+
|
|
170
|
+
while ((match = aRegex.exec(html)) !== null) {
|
|
171
|
+
const tag = match[0];
|
|
172
|
+
|
|
173
|
+
if (!hasClass(tag, "u-url") && !hasClass(tag, "u-uid")) continue;
|
|
174
|
+
|
|
175
|
+
const href = getAttr(tag, "href");
|
|
176
|
+
if (href) return href;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if an HTML tag has a specific class
|
|
184
|
+
* @param {string} tag - HTML tag string
|
|
185
|
+
* @param {string} className - Class to check for
|
|
186
|
+
* @returns {boolean}
|
|
187
|
+
*/
|
|
188
|
+
function hasClass(tag, className) {
|
|
189
|
+
const classMatch = tag.match(/class=["']([^"']*)["']/i);
|
|
190
|
+
if (!classMatch) return false;
|
|
191
|
+
return classMatch[1].split(/\s+/).includes(className);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get an attribute value from an HTML tag
|
|
196
|
+
* @param {string} tag - HTML tag string
|
|
197
|
+
* @param {string} attr - Attribute name
|
|
198
|
+
* @returns {string|null}
|
|
199
|
+
*/
|
|
200
|
+
function getAttr(tag, attr) {
|
|
201
|
+
const regex = new RegExp(`${attr}=["']([^"']*)["']`, "i");
|
|
202
|
+
const match = tag.match(regex);
|
|
203
|
+
return match ? match[1] : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve a potentially relative URL against a base
|
|
208
|
+
* @param {string} url
|
|
209
|
+
* @param {string} base
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
function resolveUrl(url, base) {
|
|
213
|
+
try {
|
|
214
|
+
return new URL(url, base).href;
|
|
215
|
+
} catch {
|
|
216
|
+
return url;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Check if a cached entry has expired
|
|
222
|
+
* @param {string} fetchedAt - ISO timestamp
|
|
223
|
+
* @returns {boolean}
|
|
224
|
+
*/
|
|
225
|
+
function isExpired(fetchedAt) {
|
|
226
|
+
if (!fetchedAt) return true;
|
|
227
|
+
const age = Date.now() - new Date(fetchedAt).getTime();
|
|
228
|
+
return age > CACHE_TTL_MS;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Clear the in-memory cache (useful for testing)
|
|
233
|
+
*/
|
|
234
|
+
export function clearMemoryCache() {
|
|
235
|
+
memoryCache.clear();
|
|
236
|
+
}
|
|
@@ -250,3 +250,52 @@ export async function deleteAll(collection) {
|
|
|
250
250
|
const result = await collection.deleteMany({});
|
|
251
251
|
return result.deletedCount;
|
|
252
252
|
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get distinct source domains that have entries with missing author photos
|
|
256
|
+
* @param {object} collection - MongoDB collection
|
|
257
|
+
* @returns {Promise<string[]>} Array of domain strings
|
|
258
|
+
*/
|
|
259
|
+
export async function getDomainsWithMissingPhotos(collection) {
|
|
260
|
+
return collection.distinct("sourceDomain", {
|
|
261
|
+
authorPhoto: { $in: [null, ""] },
|
|
262
|
+
sourceDomain: { $ne: null },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Update author photo and URL for all entries from a domain that are missing them
|
|
268
|
+
* @param {object} collection - MongoDB collection
|
|
269
|
+
* @param {string} domain - Source domain
|
|
270
|
+
* @param {object} data - Author data to set
|
|
271
|
+
* @param {string} [data.photoUrl] - Author photo URL
|
|
272
|
+
* @param {string} [data.authorUrl] - Author profile URL
|
|
273
|
+
* @returns {Promise<number>} Number of entries updated
|
|
274
|
+
*/
|
|
275
|
+
export async function updateAuthorDataByDomain(collection, domain, data) {
|
|
276
|
+
const setFields = {};
|
|
277
|
+
if (data.photoUrl) {
|
|
278
|
+
setFields.authorPhoto = data.photoUrl;
|
|
279
|
+
}
|
|
280
|
+
if (data.authorUrl) {
|
|
281
|
+
setFields.authorUrl = data.authorUrl;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (Object.keys(setFields).length === 0) return 0;
|
|
285
|
+
|
|
286
|
+
// Only update entries that are missing the data
|
|
287
|
+
const query = { sourceDomain: domain };
|
|
288
|
+
const conditions = [];
|
|
289
|
+
if (data.photoUrl) {
|
|
290
|
+
conditions.push({ authorPhoto: { $in: [null, ""] } });
|
|
291
|
+
}
|
|
292
|
+
if (data.authorUrl) {
|
|
293
|
+
conditions.push({ authorUrl: { $in: [null, ""] } });
|
|
294
|
+
}
|
|
295
|
+
if (conditions.length > 0) {
|
|
296
|
+
query.$or = conditions;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const result = await collection.updateMany(query, { $set: setFields });
|
|
300
|
+
return result.modifiedCount;
|
|
301
|
+
}
|
package/lib/sync.js
CHANGED
|
@@ -9,11 +9,14 @@ import {
|
|
|
9
9
|
getMaxWmId,
|
|
10
10
|
deleteAll,
|
|
11
11
|
hideByDomain,
|
|
12
|
+
getDomainsWithMissingPhotos,
|
|
13
|
+
updateAuthorDataByDomain,
|
|
12
14
|
} from "./storage/webmentions.js";
|
|
13
15
|
import {
|
|
14
16
|
ensureBlocklistIndexes,
|
|
15
17
|
getBlockedDomainSet,
|
|
16
18
|
} from "./storage/blocklist.js";
|
|
19
|
+
import { discoverAuthorData } from "./hcard.js";
|
|
17
20
|
|
|
18
21
|
let syncInterval = null;
|
|
19
22
|
let syncState = {
|
|
@@ -141,16 +144,20 @@ export async function runSync(dbOrIndiekit, options) {
|
|
|
141
144
|
}
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
// Enrich entries with missing author photos via h-card discovery
|
|
148
|
+
const enriched = await enrichMissingPhotos(db, wmCollection);
|
|
149
|
+
|
|
144
150
|
syncState.lastSync = new Date().toISOString();
|
|
145
151
|
syncState.syncing = false;
|
|
146
152
|
|
|
147
153
|
console.log(
|
|
148
|
-
`[Webmentions] Sync complete: ${syncState.mentionsAdded} new, ${syncState.mentionsFiltered} filtered`,
|
|
154
|
+
`[Webmentions] Sync complete: ${syncState.mentionsAdded} new, ${syncState.mentionsFiltered} filtered, ${enriched} enriched`,
|
|
149
155
|
);
|
|
150
156
|
|
|
151
157
|
return {
|
|
152
158
|
mentionsAdded: syncState.mentionsAdded,
|
|
153
159
|
mentionsFiltered: syncState.mentionsFiltered,
|
|
160
|
+
mentionsEnriched: enriched,
|
|
154
161
|
};
|
|
155
162
|
} catch (error) {
|
|
156
163
|
syncState.lastError = error.message;
|
|
@@ -232,16 +239,20 @@ export async function runFullSync(dbOrIndiekit, options) {
|
|
|
232
239
|
}
|
|
233
240
|
}
|
|
234
241
|
|
|
242
|
+
// Enrich entries with missing author photos via h-card discovery
|
|
243
|
+
const enriched = await enrichMissingPhotos(db, wmCollection);
|
|
244
|
+
|
|
235
245
|
syncState.lastSync = new Date().toISOString();
|
|
236
246
|
syncState.syncing = false;
|
|
237
247
|
|
|
238
248
|
console.log(
|
|
239
|
-
`[Webmentions] Full sync complete: ${syncState.mentionsAdded} imported, ${syncState.mentionsFiltered} filtered`,
|
|
249
|
+
`[Webmentions] Full sync complete: ${syncState.mentionsAdded} imported, ${syncState.mentionsFiltered} filtered, ${enriched} enriched`,
|
|
240
250
|
);
|
|
241
251
|
|
|
242
252
|
return {
|
|
243
253
|
mentionsAdded: syncState.mentionsAdded,
|
|
244
254
|
mentionsFiltered: syncState.mentionsFiltered,
|
|
255
|
+
mentionsEnriched: enriched,
|
|
245
256
|
};
|
|
246
257
|
} catch (error) {
|
|
247
258
|
syncState.lastError = error.message;
|
|
@@ -251,6 +262,55 @@ export async function runFullSync(dbOrIndiekit, options) {
|
|
|
251
262
|
}
|
|
252
263
|
}
|
|
253
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Enrich webmention entries that have missing author photos by discovering
|
|
267
|
+
* h-card data from the source domain's homepage.
|
|
268
|
+
* @param {object} db - MongoDB database instance
|
|
269
|
+
* @param {object} wmCollection - Webmentions collection
|
|
270
|
+
* @returns {Promise<number>} Total entries updated
|
|
271
|
+
*/
|
|
272
|
+
async function enrichMissingPhotos(db, wmCollection) {
|
|
273
|
+
let totalUpdated = 0;
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const domains = await getDomainsWithMissingPhotos(wmCollection);
|
|
277
|
+
|
|
278
|
+
if (domains.length === 0) return 0;
|
|
279
|
+
|
|
280
|
+
const cacheCollection = db.collection("hcardCache");
|
|
281
|
+
await cacheCollection.createIndex({ domain: 1 }, { unique: true });
|
|
282
|
+
|
|
283
|
+
for (const domain of domains) {
|
|
284
|
+
const data = await discoverAuthorData(domain, cacheCollection);
|
|
285
|
+
|
|
286
|
+
if (data.photoUrl || data.authorUrl) {
|
|
287
|
+
const updated = await updateAuthorDataByDomain(
|
|
288
|
+
wmCollection,
|
|
289
|
+
domain,
|
|
290
|
+
data,
|
|
291
|
+
);
|
|
292
|
+
totalUpdated += updated;
|
|
293
|
+
|
|
294
|
+
if (updated > 0) {
|
|
295
|
+
console.log(
|
|
296
|
+
`[Webmentions] Enriched ${updated} entries for ${domain}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Small delay between domain lookups
|
|
302
|
+
await delay(200);
|
|
303
|
+
}
|
|
304
|
+
} catch (error) {
|
|
305
|
+
console.error(
|
|
306
|
+
"[Webmentions] h-card enrichment error:",
|
|
307
|
+
error.message,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return totalUpdated;
|
|
312
|
+
}
|
|
313
|
+
|
|
254
314
|
/**
|
|
255
315
|
* Fetch a single page from webmention.io API
|
|
256
316
|
* @param {object} options - Plugin options (token, domain)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-webmention-io",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Webmention moderation endpoint for Indiekit. Syncs webmentions from webmention.io into MongoDB with delete, block, and privacy removal capabilities.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|