@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 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.6",
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",