@songmu/mdhq 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # mdhq - Markdown headquarters (?)
1
+ # mdhq - Markdown headquarters, probably
2
2
 
3
3
  `mdhq` saves web pages as Markdown in a [ghq](https://github.com/x-motemen/ghq)-inspired filesystem layout. It
4
4
  uses [Defuddle](https://defuddle.md/) for content extraction and keeps all state in Markdown and asset
@@ -18,6 +18,8 @@ npm install --global @songmu/mdhq
18
18
 
19
19
  ```sh
20
20
  mdhq get https://example.com/article
21
+ mdhq get https://example.com/article https://example.com/another-article
22
+ cat urls.txt | mdhq get
21
23
  mdhq get --update https://example.com/article
22
24
  mdhq get --no-assets https://example.com/article
23
25
  mdhq get --json --header 'Cookie: session=value' https://example.com/article
@@ -26,9 +28,12 @@ mdhq list --full-path
26
28
  mdhq root
27
29
  ```
28
30
 
29
- `mdhq get` prints the absolute Markdown path to stdout by default. Warnings
30
- are written to stderr. `--json` returns the requested URL, final source URL,
31
- Markdown path, status, downloaded assets, and warnings.
31
+ `mdhq get` accepts multiple URLs as arguments or one URL per line on standard
32
+ input; both sources are merged. It prints one absolute Markdown path per URL
33
+ to stdout by default. Warnings are written to stderr. `--json` returns one
34
+ result object for a single URL, or an array for multiple URLs. Requests are
35
+ limited to eight in parallel, with requests to the same host serialized and
36
+ spaced one second apart; image downloads use the same limits.
32
37
 
33
38
  `mdhq list` recursively lists `.md` files below the storage root, one per
34
39
  line, in sorted root-relative form. Use `-p` or `--full-path` to print absolute
@@ -103,6 +108,14 @@ Saved frontmatter uses Obsidian Web Clipper-compatible names such as `title`,
103
108
  conditional updates. It does not add `type` or `tags` by default; use
104
109
  `frontmatter.values` to opt into values such as `"type": "clip"`.
105
110
 
111
+ `source` is normalized after redirects. mdhq accepts an HTML canonical URL
112
+ only when its normalized origin and pathname match the fetched page; otherwise
113
+ it removes known tracking parameters with `urlpurify`, while retaining
114
+ functional WordPress preview parameters. Fragments are removed.
115
+ Queryless sources use the normal ghq-inspired path. Sources with a query use a
116
+ configured `entryQueryKey` value when available, or a deterministic MD5
117
+ filename derived from the final path segment and ordered query string.
118
+
106
119
  An update returns `updated` when the normalized Markdown body or user-facing
107
120
  frontmatter changes and `unchanged` when HTTP returns 304 or the fetched note
108
121
  content is unchanged.
package/dist/cli.d.ts CHANGED
@@ -3,6 +3,7 @@ import { Command } from "commander";
3
3
  export interface CliIo {
4
4
  stdout: Pick<NodeJS.WriteStream, "write">;
5
5
  stderr: Pick<NodeJS.WriteStream, "write">;
6
+ stdin?: NodeJS.ReadStream;
6
7
  }
7
8
  export declare function createProgram(io?: CliIo): Command;
8
9
  export declare function runCli(argv?: string[], io?: CliIo): Promise<number>;
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { MdhqError } from "./errors.js";
8
8
  import { getPage } from "./get-page.js";
9
9
  import { listMarkdownFiles } from "./list-files.js";
10
10
  import { VERSION } from "./version.js";
11
+ import { RequestScheduler } from "./http/scheduler.js";
11
12
  function collect(value, previous) {
12
13
  return [...previous, value];
13
14
  }
@@ -25,6 +26,27 @@ function parseHeaders(values) {
25
26
  };
26
27
  });
27
28
  }
29
+ async function readStdinUrls(stdin) {
30
+ if (!stdin || stdin.isTTY) {
31
+ return [];
32
+ }
33
+ const urls = [];
34
+ let remainder = "";
35
+ const decoder = new TextDecoder();
36
+ for await (const chunk of stdin) {
37
+ const text = typeof chunk === "string"
38
+ ? chunk
39
+ : decoder.decode(chunk, { stream: true });
40
+ const lines = `${remainder}${text}`.split(/\r?\n/u);
41
+ remainder = lines.pop() ?? "";
42
+ urls.push(...lines.map((line) => line.trim()).filter(Boolean));
43
+ }
44
+ remainder += decoder.decode();
45
+ if (remainder.trim()) {
46
+ urls.push(remainder.trim());
47
+ }
48
+ return urls;
49
+ }
28
50
  export function createProgram(io = process) {
29
51
  const program = new Command()
30
52
  .name("mdhq")
@@ -40,25 +62,49 @@ export function createProgram(io = process) {
40
62
  });
41
63
  program
42
64
  .command("get")
43
- .description("Fetch and save one web page.")
44
- .argument("<url>")
65
+ .description("Fetch and save web pages.")
66
+ .argument("[urls...]")
45
67
  .option("--root <path>", "storage root")
46
68
  .option("--no-assets", "do not download images")
47
69
  .option("--update", "update an existing page")
48
70
  .option("--user-agent <value>", "HTTP User-Agent")
49
71
  .option("--header <header>", "additional HTTP header", collect, [])
50
72
  .addOption(new Option("--json", "print a structured result"))
51
- .action(async (url, options) => {
52
- const result = await getPage({
53
- url,
54
- ...(options.root ? { root: options.root } : {}),
55
- ...(options.assets === false ? { assets: false } : {}),
56
- update: options.update ?? false,
57
- ...(options.userAgent ? { userAgent: options.userAgent } : {}),
58
- headers: parseHeaders(options.header),
59
- onWarning: (warning) => io.stderr.write(`warning: ${warning.message}\n`)
60
- });
61
- io.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `${result.path}\n`);
73
+ .action(async (urls, options) => {
74
+ const inputUrls = await readStdinUrls(io.stdin);
75
+ const requestedUrls = [...(urls ?? []), ...inputUrls]
76
+ .map((url) => url.trim())
77
+ .filter(Boolean);
78
+ if (requestedUrls.length === 0) {
79
+ throw new MdhqError("INVALID_URL", "At least one URL is required");
80
+ }
81
+ const scheduler = new RequestScheduler();
82
+ const results = [];
83
+ let nextIndex = 0;
84
+ const worker = async () => {
85
+ while (nextIndex < requestedUrls.length) {
86
+ const index = nextIndex;
87
+ nextIndex += 1;
88
+ const url = requestedUrls[index];
89
+ if (url === undefined) {
90
+ continue;
91
+ }
92
+ results[index] = await getPage({
93
+ url,
94
+ ...(options.root ? { root: options.root } : {}),
95
+ ...(options.assets === false ? { assets: false } : {}),
96
+ update: options.update ?? false,
97
+ ...(options.userAgent ? { userAgent: options.userAgent } : {}),
98
+ headers: parseHeaders(options.header),
99
+ scheduler,
100
+ onWarning: (warning) => io.stderr.write(`warning: ${warning.message}\n`)
101
+ });
102
+ }
103
+ };
104
+ await Promise.all(Array.from({ length: Math.min(8, requestedUrls.length) }, () => worker()));
105
+ io.stdout.write(options.json
106
+ ? `${JSON.stringify(results.length === 1 ? results[0] : results, null, 2)}\n`
107
+ : `${results.map((result) => result.path).join("\n")}\n`);
62
108
  });
63
109
  program
64
110
  .command("list")
@@ -115,5 +161,5 @@ if (process.argv[1] !== undefined) {
115
161
  }
116
162
  }
117
163
  if (isMain) {
118
- process.exitCode = await runCli();
164
+ process.exitCode = await runCli(process.argv, process);
119
165
  }
@@ -5,13 +5,9 @@ export interface FrontmatterOptions {
5
5
  sourceUrl: string;
6
6
  requestedUrl: string;
7
7
  created: Date | string;
8
- modified: Date;
9
- contentDigest: string;
8
+ modified: Date | string;
10
9
  etag?: string;
11
10
  lastModified?: string;
12
- vary?: string[];
13
- image?: string;
14
- imageSource?: string;
15
11
  config?: MdhqConfig["frontmatter"];
16
12
  }
17
13
  interface ControlledFrontmatterOptions {
@@ -19,11 +15,9 @@ interface ControlledFrontmatterOptions {
19
15
  sourceUrl: string;
20
16
  requestedUrl: string;
21
17
  created: Date | string;
22
- modified: Date;
23
- contentDigest: string;
18
+ modified: Date | string;
24
19
  etag?: string;
25
20
  lastModified?: string;
26
- vary?: string[];
27
21
  config?: MdhqConfig["frontmatter"];
28
22
  }
29
23
  export declare function buildFrontmatter(options: FrontmatterOptions): Record<string, unknown>;
@@ -1,9 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { parse, stringify } from "yaml";
3
3
  import { formatLocalRfc3339 } from "../date.js";
4
+ const REMOVED_DEFAULT_FIELDS = ["site", "domain", "image", "image_source", "word_count"];
4
5
  function applyControlledFields(options) {
5
6
  const fields = { ...options.fields };
6
7
  delete fields.type;
8
+ for (const key of REMOVED_DEFAULT_FIELDS) {
9
+ delete fields[key];
10
+ }
7
11
  for (const key of options.config?.exclude ?? []) {
8
12
  delete fields[key];
9
13
  }
@@ -21,8 +25,11 @@ function applyControlledFields(options) {
21
25
  typeof options.created === "string"
22
26
  ? options.created
23
27
  : formatLocalRfc3339(options.created);
24
- fields.modified = formatLocalRfc3339(options.modified);
25
- fields.content_digest = options.contentDigest;
28
+ fields.modified =
29
+ typeof options.modified === "string"
30
+ ? options.modified
31
+ : formatLocalRfc3339(options.modified);
32
+ delete fields.content_digest;
26
33
  if (options.etag) {
27
34
  fields.etag = options.etag;
28
35
  }
@@ -35,12 +42,7 @@ function applyControlledFields(options) {
35
42
  else {
36
43
  delete fields.last_modified;
37
44
  }
38
- if (options.vary) {
39
- fields.vary = options.vary;
40
- }
41
- else {
42
- delete fields.vary;
43
- }
45
+ delete fields.vary;
44
46
  return fields;
45
47
  }
46
48
  export function buildFrontmatter(options) {
@@ -51,25 +53,13 @@ export function buildFrontmatter(options) {
51
53
  ["author", options.metadata.author],
52
54
  ["published", options.metadata.published],
53
55
  ["updated", options.metadata.updated],
54
- ["site", options.metadata.site],
55
- ["domain", options.metadata.domain],
56
- ["language", options.metadata.language],
57
- ["word_count", options.metadata.wordCount]
56
+ ["language", options.metadata.language]
58
57
  ];
59
58
  for (const [key, value] of metadataFields) {
60
59
  if (value !== undefined && value !== "") {
61
60
  fields[key] = value;
62
61
  }
63
62
  }
64
- if (options.image) {
65
- fields.image = options.image;
66
- }
67
- else if (options.metadata.image) {
68
- fields.image = options.metadata.image;
69
- }
70
- if (options.imageSource) {
71
- fields.image_source = options.imageSource;
72
- }
73
63
  return applyControlledFields({ ...options, fields });
74
64
  }
75
65
  export function refreshFrontmatter(existing, options) {
package/dist/get-page.js CHANGED
@@ -9,7 +9,8 @@ import { fetchHtml, fetchWithEnvProxy } from "./http/fetch.js";
9
9
  import { transformMarkdown } from "./markdown/transform.js";
10
10
  import { storagePathForUrl } from "./path/storage-path.js";
11
11
  import { inspectDestination, saveDocument } from "./storage/save.js";
12
- import { normalizeHost, parseHttpUrl, sameHttpTarget } from "./url/identity.js";
12
+ import { normalizeHost, sameHttpTarget } from "./url/identity.js";
13
+ import { normalizeRequestedUrl, normalizeSourceUrl, normalizeSourceUrlWithoutCanonical } from "./url/normalize.js";
13
14
  function varyNames(value) {
14
15
  return [
15
16
  ...new Set((value ?? "")
@@ -24,8 +25,37 @@ function hasCredentialHeaders(headers) {
24
25
  return name === "authorization" || name === "cookie";
25
26
  });
26
27
  }
28
+ const NOTE_BOOKKEEPING_FIELDS = new Set([
29
+ "created",
30
+ "modified",
31
+ "etag",
32
+ "last_modified",
33
+ "content_digest",
34
+ "vary"
35
+ ]);
36
+ function normalizeComparable(value) {
37
+ if (Array.isArray(value)) {
38
+ return value.map(normalizeComparable);
39
+ }
40
+ if (value && typeof value === "object") {
41
+ return Object.fromEntries(Object.entries(value)
42
+ .sort(([left], [right]) => left.localeCompare(right))
43
+ .map(([key, entry]) => [key, normalizeComparable(entry)]));
44
+ }
45
+ return value;
46
+ }
47
+ function userFacingFrontmatter(frontmatter) {
48
+ return Object.fromEntries(Object.entries(frontmatter)
49
+ .filter(([key]) => !NOTE_BOOKKEEPING_FIELDS.has(key))
50
+ .map(([key, value]) => [key, normalizeComparable(value)])
51
+ .sort(([left], [right]) => left.localeCompare(right)));
52
+ }
53
+ function sameUserFacingFrontmatter(left, right) {
54
+ return (JSON.stringify(userFacingFrontmatter(left)) ===
55
+ JSON.stringify(userFacingFrontmatter(right)));
56
+ }
27
57
  export async function getPage(options) {
28
- const requestedUrl = parseHttpUrl(options.url).href;
58
+ const requestedUrl = normalizeRequestedUrl(options.url);
29
59
  const loaded = await loadConfig(options.configPath);
30
60
  const warnings = [];
31
61
  const warn = (warning) => {
@@ -40,7 +70,8 @@ export async function getPage(options) {
40
70
  }
41
71
  async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeadersAllowed) {
42
72
  const { options, requestedUrl, loaded, warnings, warn, root } = context;
43
- const requested = new URL(fetchUrl);
73
+ const requestedCandidateUrl = normalizeSourceUrlWithoutCanonical(fetchUrl);
74
+ const requested = new URL(requestedCandidateUrl);
44
75
  const requestedConfig = resolveHostConfig(normalizeHost(requested), requested.pathname, loaded.config.hosts ?? {});
45
76
  const requestedEntryKey = requestedConfig?.entryQueryKey ?? undefined;
46
77
  const requestedPath = storagePathForUrl({
@@ -48,7 +79,7 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
48
79
  url: requested,
49
80
  ...(requestedEntryKey ? { entryQueryKey: requestedEntryKey } : {})
50
81
  });
51
- const requestedExisting = await inspectDestination(requestedPath, fetchUrl, requestedEntryKey, root);
82
+ const requestedExisting = await inspectDestination(requestedPath, requestedCandidateUrl, requestedEntryKey, root);
52
83
  if (requestedExisting && !options.update) {
53
84
  return {
54
85
  requestedUrl,
@@ -59,6 +90,11 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
59
90
  warnings
60
91
  };
61
92
  }
93
+ if (options.update &&
94
+ requestedExisting &&
95
+ !sameHttpTarget(requestedCandidateUrl, fetchUrl)) {
96
+ return getPageAttempt(context, requestedCandidateUrl, retriesRemaining, callerHeadersAllowed);
97
+ }
62
98
  const headers = callerHeadersAllowed ? options.headers : [];
63
99
  const userAgent = options.userAgent ?? loaded.config.userAgent;
64
100
  const timeoutMs = options.timeoutMs ?? loaded.config.timeoutMs;
@@ -69,12 +105,12 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
69
105
  ...(userAgent ? { userAgent } : {}),
70
106
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
71
107
  ...(maxResponseBytes !== undefined ? { maxResponseBytes } : {}),
72
- ...(maxRedirects !== undefined ? { maxRedirects } : {})
108
+ ...(maxRedirects !== undefined ? { maxRedirects } : {}),
109
+ ...(options.scheduler ? { scheduler: options.scheduler } : {})
73
110
  };
74
111
  let conditional;
75
112
  if (options.update &&
76
113
  requestedExisting &&
77
- requestedExisting.vary?.length === 0 &&
78
114
  !hasCredentialHeaders(headers) &&
79
115
  sameHttpTarget(requestedExisting.sourceUrl, fetchUrl)) {
80
116
  if (requestedExisting.etag) {
@@ -126,17 +162,11 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
126
162
  created: isRfc3339DateTime(requestedExisting.created)
127
163
  ? requestedExisting.created
128
164
  : now,
129
- modified: now,
130
- contentDigest: requestedExisting.contentDigest,
165
+ modified: requestedExisting.modified ?? now,
131
166
  ...(etag ? { etag } : {}),
132
167
  ...(reusableLastModified
133
168
  ? { lastModified: reusableLastModified }
134
169
  : {}),
135
- ...(vary.length > 0
136
- ? { vary }
137
- : etag || reusableLastModified
138
- ? { vary: [] }
139
- : {}),
140
170
  ...(loaded.config.frontmatter ? { config: loaded.config.frontmatter } : {})
141
171
  });
142
172
  const content = serializeDocument(frontmatter, requestedExisting.markdown);
@@ -159,32 +189,34 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
159
189
  requestedUrl,
160
190
  sourceUrl: requestedExisting.sourceUrl,
161
191
  path: requestedPath,
162
- status: storageStatus === "updated" ? "unchanged" : storageStatus,
192
+ status: storageStatus === "saved" ? "saved" : "unchanged",
163
193
  assets: [],
164
194
  warnings
165
195
  };
166
196
  }
167
- const finalUrl = new URL(fetched.finalUrl);
168
- const matchedConfig = resolveHostConfig(normalizeHost(finalUrl), finalUrl.pathname, loaded.config.hosts ?? {});
197
+ const finalResponseUrl = new URL(fetched.finalUrl);
198
+ const sourceUrl = normalizeSourceUrl(fetched.html, finalResponseUrl);
199
+ const normalizedSource = new URL(sourceUrl);
200
+ const matchedConfig = resolveHostConfig(normalizeHost(normalizedSource), normalizedSource.pathname, loaded.config.hosts ?? {});
169
201
  const entryQueryKey = matchedConfig?.entryQueryKey ?? undefined;
170
202
  const markdownPath = storagePathForUrl({
171
203
  root,
172
- url: finalUrl,
204
+ url: normalizedSource,
173
205
  ...(entryQueryKey ? { entryQueryKey } : {})
174
206
  });
175
- const existing = await inspectDestination(markdownPath, finalUrl.href, entryQueryKey, root);
207
+ const existing = await inspectDestination(markdownPath, sourceUrl, entryQueryKey, root);
176
208
  if (options.update &&
177
209
  existing &&
178
210
  markdownPath !== requestedPath) {
179
211
  if (retriesRemaining === 0) {
180
212
  throw new MdhqError("STORAGE_ERROR", `Redirect destination changed repeatedly while updating ${markdownPath}`);
181
213
  }
182
- return getPageAttempt(context, finalUrl.href, retriesRemaining - 1, responseHeadersAllowed);
214
+ return getPageAttempt(context, sourceUrl, retriesRemaining - 1, responseHeadersAllowed);
183
215
  }
184
216
  if (existing && !options.update) {
185
217
  return {
186
218
  requestedUrl,
187
- sourceUrl: finalUrl.href,
219
+ sourceUrl,
188
220
  path: markdownPath,
189
221
  status: "skipped",
190
222
  assets: [],
@@ -195,7 +227,7 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
195
227
  const responseCredentialed = responseHeadersAllowed && hasCredentialHeaders(http.headers);
196
228
  const converted = await convertHtml({
197
229
  html: fetched.html,
198
- url: finalUrl,
230
+ url: finalResponseUrl,
199
231
  defuddle: {
200
232
  ...loaded.config.defuddle,
201
233
  fetch: fetchWithEnvProxy,
@@ -208,7 +240,7 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
208
240
  let metadata = converted.metadata;
209
241
  if (converted.metadata.image) {
210
242
  try {
211
- const image = new URL(converted.metadata.image, finalUrl);
243
+ const image = new URL(converted.metadata.image, finalResponseUrl);
212
244
  if (image.protocol !== "http:" && image.protocol !== "https:") {
213
245
  throw new TypeError(`Unsupported image URL scheme: ${image.protocol}`);
214
246
  }
@@ -223,11 +255,11 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
223
255
  warn({
224
256
  code: "INVALID_IMAGE_URL",
225
257
  message: `Invalid representative image URL: ${converted.metadata.image}`,
226
- url: finalUrl.href
258
+ url: finalResponseUrl.href
227
259
  });
228
260
  }
229
261
  }
230
- const transformed = transformMarkdown(converted.markdown, finalUrl.href);
262
+ const transformed = transformMarkdown(converted.markdown, finalResponseUrl.href);
231
263
  const assetsEnabled = options.assets ?? loaded.config.assets ?? true;
232
264
  const localized = assetsEnabled
233
265
  ? await localizeAssets({
@@ -236,7 +268,7 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
236
268
  ...(metadata.image ? { representativeImage: metadata.image } : {}),
237
269
  markdownPath,
238
270
  root,
239
- baseUrl: finalUrl.href,
271
+ baseUrl: finalResponseUrl.href,
240
272
  http: responseHeadersAllowed ? http : { ...http, headers: [] },
241
273
  warn
242
274
  })
@@ -251,36 +283,36 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
251
283
  const contentDigest = markdownContentDigest(localized.markdown);
252
284
  const lastModified = normalizeLastModified(fetched.lastModified);
253
285
  const vary = varyNames(fetched.vary);
254
- const validatorsReusable = vary.length === 0 && !responseCredentialed;
286
+ const validatorsReusable = vary.length === 0 &&
287
+ !responseCredentialed &&
288
+ sameHttpTarget(sourceUrl, finalResponseUrl);
255
289
  const etag = validatorsReusable ? fetched.etag : undefined;
256
290
  const reusableLastModified = validatorsReusable ? lastModified : undefined;
257
- const frontmatter = buildFrontmatter({
291
+ const nextFrontmatterOptions = {
258
292
  metadata,
259
- sourceUrl: finalUrl.href,
293
+ sourceUrl,
260
294
  requestedUrl,
261
295
  created,
262
296
  modified: now,
263
- contentDigest,
264
297
  ...(etag ? { etag } : {}),
265
298
  ...(reusableLastModified ? { lastModified: reusableLastModified } : {}),
266
- ...(vary.length > 0
267
- ? { vary }
268
- : etag || reusableLastModified
269
- ? { vary: [] }
270
- : {}),
271
- ...(localized.representativeImage
272
- ? { image: localized.representativeImage }
273
- : {}),
274
- ...(localized.representativeImageSource
275
- ? { imageSource: localized.representativeImageSource }
276
- : {}),
277
299
  ...(loaded.config.frontmatter ? { config: loaded.config.frontmatter } : {})
278
- });
300
+ };
301
+ const nextFrontmatter = buildFrontmatter(nextFrontmatterOptions);
302
+ const noteChanged = !expectedExisting ||
303
+ expectedExisting.contentDigest !== contentDigest ||
304
+ !sameUserFacingFrontmatter(expectedExisting.frontmatter, nextFrontmatter);
305
+ const frontmatter = noteChanged
306
+ ? nextFrontmatter
307
+ : buildFrontmatter({
308
+ ...nextFrontmatterOptions,
309
+ modified: expectedExisting.modified ?? now
310
+ });
279
311
  const content = serializeDocument(frontmatter, localized.markdown);
280
312
  const storageStatus = await saveDocument({
281
313
  path: markdownPath,
282
314
  content,
283
- sourceUrl: finalUrl.href,
315
+ sourceUrl,
284
316
  update: options.update ?? false,
285
317
  ...(options.update
286
318
  ? { expectedContent: expectedExisting?.content ?? null }
@@ -296,10 +328,11 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
296
328
  }
297
329
  return {
298
330
  requestedUrl,
299
- sourceUrl: finalUrl.href,
331
+ sourceUrl,
300
332
  path: markdownPath,
301
- status: storageStatus === "updated" &&
302
- expectedExisting?.contentDigest === contentDigest
333
+ status: expectedExisting &&
334
+ (storageStatus === "updated" || storageStatus === "skipped") &&
335
+ !noteChanged
303
336
  ? "unchanged"
304
337
  : storageStatus,
305
338
  assets: localized.assets,
@@ -1,4 +1,5 @@
1
1
  import type { HeaderValue } from "../types.js";
2
+ import type { RequestScheduler } from "./scheduler.js";
2
3
  export interface FetchResourceOptions {
3
4
  headers?: HeaderValue[];
4
5
  userAgent?: string;
@@ -11,6 +12,7 @@ export interface FetchResourceOptions {
11
12
  etag?: string;
12
13
  lastModified?: string;
13
14
  };
15
+ scheduler?: RequestScheduler;
14
16
  }
15
17
  export interface FetchedResource {
16
18
  body: Uint8Array;
@@ -73,12 +73,13 @@ export async function fetchResource(input, options = {}) {
73
73
  for (let redirects = 0;; redirects += 1) {
74
74
  let response;
75
75
  try {
76
- response = (await undiciFetch(url, {
76
+ const fetch = () => undiciFetch(url, {
77
77
  dispatcher: proxyAgent,
78
78
  headers: requestHeaders(options, customHeadersAllowed, redirects === 0),
79
79
  redirect: "manual",
80
80
  signal: AbortSignal.timeout(timeoutMs)
81
- }));
81
+ });
82
+ response = options.scheduler ? await options.scheduler.run(url, fetch) : await fetch();
82
83
  }
83
84
  catch (error) {
84
85
  throw new MdhqError("FETCH_FAILED", `Failed to fetch ${url.href}`, { cause: error });
@@ -0,0 +1,12 @@
1
+ export declare class RequestScheduler {
2
+ private readonly maxConcurrent;
3
+ private readonly hostIntervalMs;
4
+ private active;
5
+ private readonly pending;
6
+ private readonly activeHosts;
7
+ private readonly lastStarted;
8
+ private timer;
9
+ constructor(maxConcurrent?: number, hostIntervalMs?: number);
10
+ run<T>(url: string | URL, task: () => Promise<T>): Promise<T>;
11
+ private pump;
12
+ }
@@ -0,0 +1,69 @@
1
+ export class RequestScheduler {
2
+ maxConcurrent;
3
+ hostIntervalMs;
4
+ active = 0;
5
+ pending = [];
6
+ activeHosts = new Set();
7
+ lastStarted = new Map();
8
+ timer;
9
+ constructor(maxConcurrent = 8, hostIntervalMs = 1_000) {
10
+ this.maxConcurrent = maxConcurrent;
11
+ this.hostIntervalMs = hostIntervalMs;
12
+ }
13
+ async run(url, task) {
14
+ const host = new URL(url).hostname.toLowerCase();
15
+ await new Promise((resolve) => {
16
+ this.pending.push({ host, run: resolve });
17
+ this.pump();
18
+ });
19
+ try {
20
+ return await task();
21
+ }
22
+ finally {
23
+ this.active -= 1;
24
+ this.activeHosts.delete(host);
25
+ this.pump();
26
+ }
27
+ }
28
+ pump() {
29
+ if (this.timer) {
30
+ clearTimeout(this.timer);
31
+ this.timer = undefined;
32
+ }
33
+ if (this.active >= this.maxConcurrent) {
34
+ return;
35
+ }
36
+ const now = Date.now();
37
+ let waitMs;
38
+ const index = this.pending.findIndex(({ host }) => {
39
+ if (this.activeHosts.has(host)) {
40
+ return false;
41
+ }
42
+ const remaining = (this.lastStarted.get(host) ?? 0) + this.hostIntervalMs - now;
43
+ if (remaining > 0) {
44
+ waitMs = waitMs === undefined ? remaining : Math.min(waitMs, remaining);
45
+ return false;
46
+ }
47
+ return true;
48
+ });
49
+ if (index < 0) {
50
+ if (waitMs !== undefined) {
51
+ this.timer = setTimeout(() => {
52
+ this.timer = undefined;
53
+ this.pump();
54
+ }, waitMs);
55
+ }
56
+ return;
57
+ }
58
+ const item = this.pending.splice(index, 1)[0];
59
+ if (!item) {
60
+ return;
61
+ }
62
+ const { host, run } = item;
63
+ this.active += 1;
64
+ this.activeHosts.add(host);
65
+ this.lastStarted.set(host, Date.now());
66
+ run();
67
+ this.pump();
68
+ }
69
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { MdhqError } from "../errors.js";
4
- import { createUrlIdentity, parseHttpUrl } from "../url/identity.js";
4
+ import { createUrlIdentity, parseHttpUrl, queryTailHash } from "../url/identity.js";
5
5
  import { decodeUrlPathSegment, storageBasename } from "../url/pathname.js";
6
6
  const INVALID_CHARACTERS = /[\/\\:*?"<>|\u0000-\u001f\u007f]/u;
7
7
  const WINDOWS_DEVICES = /^(con|prn|aux|nul|com(?:[1-9]|[¹²³])|lpt(?:[1-9]|[¹²³]))(?:\.|$)/iu;
@@ -66,13 +66,32 @@ export function storagePathForUrl(options) {
66
66
  const url = parseHttpUrl(options.url);
67
67
  const identity = createUrlIdentity(url, options.entryQueryKey);
68
68
  const rawSegments = url.pathname.split("/").filter(Boolean);
69
- const directories = rawSegments.slice(0, -1).map((segment) => fitSegment(segment));
69
+ const queryHash = identity.entryValue === undefined ? queryTailHash(url) : undefined;
70
+ const directorySegments = (queryHash && url.pathname.endsWith("/")
71
+ ? rawSegments
72
+ : rawSegments.slice(0, -1));
73
+ if (queryHash && url.pathname.endsWith("/")) {
74
+ if (directorySegments.length === 0) {
75
+ directorySegments.push("index");
76
+ }
77
+ else {
78
+ const finalIndex = directorySegments.length - 1;
79
+ const finalSegment = directorySegments[finalIndex];
80
+ if (finalSegment !== undefined) {
81
+ directorySegments[finalIndex] = encodeURIComponent(storageBasename(finalSegment));
82
+ }
83
+ }
84
+ }
85
+ const directories = directorySegments.map((segment) => fitSegment(segment));
70
86
  let filename;
71
87
  if (identity.entryValue !== undefined) {
72
88
  const pageSegment = rawSegments.at(-1) ?? "index";
73
89
  directories.push(fitSegment(pageSegment));
74
90
  filename = `${fitSegment(encodeURIComponent(identity.entryValue), ".md")}.md`;
75
91
  }
92
+ else if (queryHash) {
93
+ filename = `${queryHash}.md`;
94
+ }
76
95
  else if (rawSegments.length === 0) {
77
96
  filename = "index.md";
78
97
  }
@@ -14,9 +14,9 @@ export interface ExistingDocument {
14
14
  markdown: string;
15
15
  contentDigest: string;
16
16
  created?: string;
17
+ modified?: string;
17
18
  etag?: string;
18
19
  lastModified?: string;
19
- vary?: string[];
20
20
  }
21
21
  export declare function readExistingDocument(filePath: string): Promise<ExistingDocument | undefined>;
22
22
  export declare function inspectDestination(filePath: string, sourceUrl: string, entryQueryKey?: string, root?: string): Promise<ExistingDocument | undefined>;
@@ -29,13 +29,12 @@ export async function readExistingDocument(filePath) {
29
29
  markdown: parsed.markdown,
30
30
  contentDigest: markdownContentDigest(parsed.markdown),
31
31
  ...(typeof frontmatter.created === "string" ? { created: frontmatter.created } : {}),
32
+ ...(typeof frontmatter.modified === "string"
33
+ ? { modified: frontmatter.modified }
34
+ : {}),
32
35
  ...(typeof frontmatter.etag === "string" ? { etag: frontmatter.etag } : {}),
33
36
  ...(typeof frontmatter.last_modified === "string"
34
37
  ? { lastModified: frontmatter.last_modified }
35
- : {}),
36
- ...(Array.isArray(frontmatter.vary) &&
37
- frontmatter.vary.every((value) => typeof value === "string")
38
- ? { vary: frontmatter.vary }
39
38
  : {})
40
39
  };
41
40
  }
@@ -81,6 +80,9 @@ export async function saveDocument(options) {
81
80
  if (!options.update) {
82
81
  return "skipped";
83
82
  }
83
+ if (existing.content === options.content) {
84
+ return "skipped";
85
+ }
84
86
  }
85
87
  if (existing) {
86
88
  await replaceFileAtomic(options.path, options.content, {
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { DefuddleOptions } from "defuddle/node";
2
+ import type { RequestScheduler } from "./http/scheduler.js";
2
3
  export interface MdhqWarning {
3
4
  code: string;
4
5
  message: string;
@@ -44,6 +45,7 @@ export interface GetPageOptions {
44
45
  useAsync?: boolean;
45
46
  now?: () => Date;
46
47
  onWarning?: (warning: MdhqWarning) => void;
48
+ scheduler?: RequestScheduler;
47
49
  }
48
50
  export interface AssetResult {
49
51
  sourceUrl: string;
@@ -3,7 +3,10 @@ export interface UrlIdentity {
3
3
  pathname: string;
4
4
  entryKey?: string;
5
5
  entryValue?: string;
6
+ queryHash?: string;
6
7
  }
8
+ export declare function queryTail(url: URL): string;
9
+ export declare function queryTailHash(url: URL): string | undefined;
7
10
  export declare function parseHttpUrl(input: string | URL): URL;
8
11
  export declare function normalizeHost(url: URL): string;
9
12
  export declare function createUrlIdentity(input: string | URL, entryQueryKey?: string): UrlIdentity;
@@ -1,6 +1,22 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { domainToASCII } from "node:url";
2
3
  import { MdhqError } from "../errors.js";
3
4
  import { canonicalPathname } from "./pathname.js";
5
+ export function queryTail(url) {
6
+ if (!url.search) {
7
+ return "";
8
+ }
9
+ if (url.pathname.endsWith("/")) {
10
+ return url.search;
11
+ }
12
+ return `${url.pathname.split("/").at(-1) ?? ""}${url.search}`;
13
+ }
14
+ export function queryTailHash(url) {
15
+ const tail = queryTail(url);
16
+ return tail
17
+ ? createHash("md5").update(tail, "utf8").digest("hex")
18
+ : undefined;
19
+ }
4
20
  export function parseHttpUrl(input) {
5
21
  let url;
6
22
  try {
@@ -23,8 +39,10 @@ export function normalizeHost(url) {
23
39
  }
24
40
  export function createUrlIdentity(input, entryQueryKey) {
25
41
  const url = parseHttpUrl(input);
26
- const entryValue = entryQueryKey ? url.searchParams.get(entryQueryKey) : null;
27
- const hasEntryValue = entryValue !== null && entryValue !== "";
42
+ const entryValue = entryQueryKey
43
+ ? url.searchParams.getAll(entryQueryKey).find((value) => value !== "")
44
+ : undefined;
45
+ const hasEntryValue = entryValue !== undefined;
28
46
  const identity = {
29
47
  host: normalizeHost(url),
30
48
  pathname: canonicalPathname(url.pathname || "/", hasEntryValue)
@@ -33,12 +51,20 @@ export function createUrlIdentity(input, entryQueryKey) {
33
51
  identity.entryKey = entryQueryKey;
34
52
  identity.entryValue = entryValue.normalize("NFC");
35
53
  }
54
+ else {
55
+ const hash = queryTailHash(url);
56
+ if (hash) {
57
+ identity.queryHash = hash;
58
+ }
59
+ }
36
60
  return identity;
37
61
  }
38
62
  export function serializeUrlIdentity(identity) {
39
63
  const entry = identity.entryKey && identity.entryValue
40
- ? `?${encodeURIComponent(identity.entryKey)}=${encodeURIComponent(identity.entryValue)}`
41
- : "";
64
+ ? `?entry-key=${encodeURIComponent(identity.entryKey)}&entry-value=${encodeURIComponent(identity.entryValue)}`
65
+ : identity.queryHash
66
+ ? `?query-md5=${identity.queryHash}`
67
+ : "";
42
68
  return `//${identity.host}${identity.pathname}${entry}`;
43
69
  }
44
70
  export function sameUrlIdentity(left, right, entryQueryKey) {
@@ -0,0 +1,3 @@
1
+ export declare function normalizeRequestedUrl(input: string | URL): string;
2
+ export declare function normalizeSourceUrlWithoutCanonical(input: string | URL): string;
3
+ export declare function normalizeSourceUrl(html: string, finalUrlInput: string | URL): string;
@@ -0,0 +1,83 @@
1
+ import { parseHTML } from "linkedom";
2
+ import { defaultTrackingParams, stripTrackingParams } from "urlpurify";
3
+ import { canonicalPathname } from "./pathname.js";
4
+ import { normalizeHost, parseHttpUrl } from "./identity.js";
5
+ const FUNCTIONAL_PAGE_PARAMS = new Set([
6
+ "preview",
7
+ "preview_id",
8
+ "preview_nonce"
9
+ ]);
10
+ const PAGE_TRACKING_PARAMS = defaultTrackingParams.filter((parameter) => typeof parameter !== "string" ||
11
+ !FUNCTIONAL_PAGE_PARAMS.has(parameter.toLowerCase()));
12
+ function withoutFragment(url) {
13
+ const normalized = new URL(url.href);
14
+ normalized.hash = "";
15
+ return normalized;
16
+ }
17
+ function normalizedOrigin(url) {
18
+ return `${url.protocol}//${normalizeHost(url)}`;
19
+ }
20
+ function canonicalUrl(html, finalUrl) {
21
+ let document;
22
+ try {
23
+ ({ document } = parseHTML(html));
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ const canonicalLinks = [...document.querySelectorAll("link[href]")].filter((link) => (link.getAttribute("rel") ?? "")
29
+ .split(/\s+/u)
30
+ .some((token) => token.toLowerCase() === "canonical"));
31
+ if (canonicalLinks.length !== 1) {
32
+ return undefined;
33
+ }
34
+ let baseUrl = finalUrl;
35
+ const baseHref = document.querySelector("base[href]")?.getAttribute("href");
36
+ if (baseHref) {
37
+ try {
38
+ baseUrl = parseHttpUrl(new URL(baseHref, finalUrl));
39
+ }
40
+ catch {
41
+ baseUrl = finalUrl;
42
+ }
43
+ }
44
+ const href = canonicalLinks[0]?.getAttribute("href");
45
+ if (!href) {
46
+ return undefined;
47
+ }
48
+ try {
49
+ return parseHttpUrl(new URL(href, baseUrl));
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ function isCanonicalEquivalent(finalUrl, canonical) {
56
+ return (canonical.username === "" &&
57
+ canonical.password === "" &&
58
+ normalizedOrigin(finalUrl) === normalizedOrigin(canonical) &&
59
+ canonicalPathname(finalUrl.pathname || "/") ===
60
+ canonicalPathname(canonical.pathname || "/"));
61
+ }
62
+ export function normalizeRequestedUrl(input) {
63
+ return withoutFragment(parseHttpUrl(input)).href;
64
+ }
65
+ export function normalizeSourceUrlWithoutCanonical(input) {
66
+ const url = withoutFragment(parseHttpUrl(input));
67
+ return withoutFragment(parseHttpUrl(stripTrackingParams(url.href, PAGE_TRACKING_PARAMS))).href;
68
+ }
69
+ export function normalizeSourceUrl(html, finalUrlInput) {
70
+ const finalUrl = withoutFragment(parseHttpUrl(finalUrlInput));
71
+ const canonical = canonicalUrl(html, finalUrl);
72
+ if (canonical) {
73
+ try {
74
+ if (isCanonicalEquivalent(finalUrl, canonical)) {
75
+ return withoutFragment(canonical).href;
76
+ }
77
+ }
78
+ catch {
79
+ // Ignore malformed optional canonical metadata.
80
+ }
81
+ }
82
+ return normalizeSourceUrlWithoutCanonical(finalUrl);
83
+ }
@@ -120,6 +120,10 @@ Use the dedicated User-Agent option for replacement.
120
120
  credentials such as cookies or authorization tokens from being stored in the
121
121
  configuration file.
122
122
 
123
+ After fetching and source normalization, host and path configuration is
124
+ resolved again against the normalized source URL. This final match determines
125
+ the effective `entryQueryKey` and storage destination.
126
+
123
127
  ## Defuddle options
124
128
 
125
129
  Supported `defuddle` fields:
@@ -219,6 +223,11 @@ Selection order:
219
223
  2. The matching glob with the greatest number of fixed literal characters.
220
224
  3. No host configuration.
221
225
 
226
+ When the effective `entryQueryKey` has multiple values, mdhq uses the first
227
+ non-empty value and ignores the remaining query parameters for storage
228
+ identity. When no non-empty value exists, the URL uses the generic ordered
229
+ query-string MD5 destination rather than the queryless destination.
230
+
222
231
  If multiple matching patterns have equal specificity, processing fails with
223
232
  `CONFIG_ERROR`. Multiple patterns that normalize to the same exact host are
224
233
  also an error.
@@ -238,5 +247,7 @@ Rules:
238
247
  - A matching path-level string overrides the host value.
239
248
  - A matching path-level `null` disables the host value.
240
249
  - A matching path object that omits `entryQueryKey` inherits the host value.
241
- - A missing or empty parameter value falls back to normal path-based storage.
242
- - All non-selected query parameters are ignored.
250
+ - When at least one non-empty selected value exists, all non-selected query
251
+ parameters are ignored.
252
+ - When no non-empty selected value exists, a remaining query falls back to the
253
+ generic ordered query-string MD5 destination.
@@ -159,14 +159,16 @@ interface GetPageResult {
159
159
  }
160
160
  ```
161
161
 
162
- - `requestedUrl` is the input after WHATWG URL serialization. This lowercases
163
- and IDNA-normalizes the host, removes an explicit default port, and retains
164
- the query and fragment. This serialization is separate from URL identity,
165
- which ignores the scheme, fragment, and unselected query parameters.
166
- - `sourceUrl` is the WHATWG-serialized final URL maintained by the redirect
167
- loop. Its query and fragment are whatever remain on that final URL after
168
- URL resolution. It is exactly the value written to frontmatter `source`.
169
- For a pre-fetch skip, it is the existing document's stored `source` value.
162
+ - `requestedUrl` is the input after WHATWG URL serialization and fragment
163
+ removal. This lowercases and IDNA-normalizes the host and removes an explicit
164
+ default port.
165
+ - `sourceUrl` is the normalized source URL written to frontmatter `source`.
166
+ After redirects, mdhq accepts one HTML canonical URL when its normalized
167
+ origin and pathname match the final response URL. Invalid, ambiguous,
168
+ cross-origin, or path-divergent canonical URLs are ignored. Without an
169
+ accepted canonical, mdhq uses `urlpurify` to remove known tracking
170
+ parameters from the final response URL. Fragments are always removed.
171
+ For a pre-fetch skip, `sourceUrl` is the existing document's stored source.
170
172
  - `path` is the absolute Markdown path.
171
173
  - `assets` is empty when page processing is skipped.
172
174
  - `warnings` contains configuration and asset warnings.
@@ -187,6 +189,11 @@ for the same HTTP target and does not include credentials. `etag` and
187
189
  `last_modified` are stored in Markdown frontmatter only when the response is
188
190
  safe to revalidate; `Vary` and body digests are not serialized.
189
191
 
192
+ The fast pre-fetch skip is retained. If the requested URL candidate already
193
+ maps to an existing document and `update` is false, mdhq does not make a
194
+ network request and therefore cannot observe remote redirect or canonical
195
+ changes.
196
+
190
197
  ### Asset results
191
198
 
192
199
  ```ts
@@ -93,22 +93,31 @@ create it.
93
93
 
94
94
  1. Validate that the requested URL uses HTTP or HTTPS.
95
95
  2. Load the JSON configuration and collect unknown-key warnings.
96
- 3. Resolve the storage root and host/path-specific entry query key.
97
- 4. Check whether the requested URL already has a same-identity destination.
96
+ 3. Resolve the storage root and a pre-fetch host/path-specific entry query key.
97
+ 4. Remove known tracking parameters from the requested URL candidate and check
98
+ whether it already has a same-identity destination.
98
99
  5. Fetch the page when it cannot be skipped before network access.
99
- 6. Resolve the final URL after redirects and recalculate configuration and
100
- destination from that URL.
101
- 7. Check the final destination for another same-identity skip.
102
- 8. Convert the fetched HTML to Markdown with Defuddle.
103
- 9. Normalize ordinary links and discover image links.
104
- 10. Download supported images and rewrite successful image destinations.
105
- 11. Normalize the Markdown body and calculate its SHA-256 content digest.
106
- 12. Build YAML frontmatter.
107
- 13. Save the Markdown with collision-safe create or update behavior.
108
-
109
- The final URL determines the destination and the `source` frontmatter field.
110
- The original URL is retained as `requested_url` only when it differs from the
111
- final URL.
100
+ 6. Resolve the final response URL after redirects.
101
+ 7. Derive the normalized source URL from an accepted HTML canonical URL or
102
+ `urlpurify` tracking-parameter removal.
103
+ 8. Recalculate configuration and destination from the normalized source and
104
+ check the final destination for another same-identity skip.
105
+ 9. Convert the fetched HTML to Markdown with Defuddle using the final response
106
+ URL as its base URL.
107
+ 10. Normalize ordinary links and discover image links using the final response
108
+ URL.
109
+ 11. Download supported images and rewrite successful image destinations.
110
+ 12. Normalize the Markdown body and calculate its SHA-256 content digest.
111
+ 13. Build YAML frontmatter.
112
+ 14. Save the Markdown with collision-safe create or update behavior.
113
+
114
+ The normalized source determines the destination and the `source` frontmatter
115
+ field. The fragment-free WHATWG-serialized original URL is retained as
116
+ `requested_url` only when it differs from the normalized source. Intermediate
117
+ redirect URLs are not stored.
118
+
119
+ The pre-fetch skip is intentionally retained. When it succeeds, mdhq performs
120
+ no request and cannot discover later redirect or canonical changes.
112
121
 
113
122
  ## HTTP behavior
114
123
 
@@ -136,8 +145,8 @@ With `update`, mdhq uses validators from a recognized existing destination:
136
145
  5. Otherwise perform an ordinary GET.
137
146
 
138
147
  Storage identity is intentionally broader than HTTP validator scope. A
139
- same-identity URL with a different scheme, query, or path alias is fetched
140
- without stored validators.
148
+ same-identity URL with a different scheme, ignored secondary query parameter
149
+ under an entry key, or path alias is fetched without stored validators.
141
150
 
142
151
  Automatic validators are sent only on the first request and are not forwarded
143
152
  after redirects. When a stored validator is available, it replaces any
@@ -271,12 +280,14 @@ Identity includes:
271
280
  - normalized host
272
281
  - canonical pathname
273
282
  - one configured entry query key and value, when present and non-empty
283
+ - otherwise, the MD5 digest of the ordered query tail when a query remains
274
284
 
275
285
  Identity ignores:
276
286
 
277
287
  - the difference between HTTP and HTTPS
278
288
  - fragments
279
- - all query parameters except the selected entry query key
289
+ - query parameters other than a selected non-empty entry key when such a key
290
+ is used
280
291
 
281
292
  Host normalization:
282
293
 
@@ -298,8 +309,42 @@ Path normalization:
298
309
  - removes a final recognized HTML extension for identity comparison
299
310
  - applies canonical percent encoding for identity comparison
300
311
 
301
- The first value returned for the configured entry query key is used. A missing
302
- or empty value causes the URL to use its normal path-based identity.
312
+ The first non-empty value returned for the configured entry query key is used.
313
+ When no non-empty value exists, a remaining query uses the generic query-tail
314
+ MD5 identity.
315
+
316
+ ## Source URL normalization
317
+
318
+ After redirects, mdhq examines `link` elements whose whitespace-separated
319
+ `rel` tokens contain `canonical`, case-insensitively.
320
+
321
+ - Exactly one canonical link must be present.
322
+ - A relative canonical is resolved against the document base URL. A valid
323
+ `<base href>` is honored; an invalid base falls back to the final response
324
+ URL.
325
+ - The resolved canonical must use HTTP or HTTPS.
326
+ - Canonical URLs containing a username or password are rejected.
327
+ - Its normalized origin and canonical pathname must equal those of the final
328
+ response URL. Scheme, normalized host, port, and pathname aliases therefore
329
+ cannot point to another content location.
330
+ - An accepted canonical is WHATWG-serialized and has its fragment removed. It
331
+ is not passed through `urlpurify`.
332
+
333
+ When canonical is absent or rejected, mdhq applies
334
+ `urlpurify.stripTrackingParams()` to the final response URL. URL wrapper
335
+ unwrapping is not used. The result is validated as HTTP(S), WHATWG-serialized,
336
+ and stripped of its fragment. Functional WordPress preview parameters
337
+ (`preview`, `preview_id`, and `preview_nonce`) are retained because mdhq
338
+ accepts arbitrary page URLs rather than feed URLs only.
339
+
340
+ The normalized source is used for host/path configuration, storage identity,
341
+ destination, and frontmatter. The final response URL is used as the base for
342
+ Defuddle conversion, ordinary links, images, and assets, as well as for HTTP
343
+ redirect and credential handling. This allows storage-equivalent canonical
344
+ aliases such as `/article` and `/article/` without changing relative URL
345
+ targets.
346
+
347
+ Re-fetching a path-divergent canonical target is not currently supported.
303
348
 
304
349
  ## Storage paths
305
350
 
@@ -347,13 +392,48 @@ https://example.com/blog/blog.php?entry_id=123
347
392
  -> example.com/blog/blog.php/123.md
348
393
  ```
349
394
 
350
- Other query parameters and the fragment do not affect the destination.
395
+ Other query parameters and the fragment do not affect the destination when a
396
+ non-empty entry value is selected.
351
397
 
352
- The query value comes from `URLSearchParams.get`, so percent escapes are
353
- decoded and `+` is interpreted as a space. The resulting value then uses the
354
- same NFC normalization, unsafe-character encoding, reserved-name handling,
355
- 240-byte limit, and MD5 fallback as a URL path segment. A slash in the query
356
- value becomes `%2F` inside the filename and never creates another directory.
398
+ Entry values are inspected in URL order and the first non-empty value is used,
399
+ so percent escapes are decoded and `+` is interpreted as a space. The
400
+ resulting value then uses the same NFC normalization, unsafe-character
401
+ encoding, reserved-name handling, 240-byte limit, and MD5 fallback as a URL
402
+ path segment. A slash in the query value becomes `%2F` inside the filename and
403
+ never creates another directory.
404
+
405
+ ### Generic query destinations
406
+
407
+ When a normalized source retains a query and has no usable configured entry
408
+ value, mdhq creates a lowercase 32-character MD5 digest from UTF-8 bytes. Query
409
+ parameter order is preserved.
410
+
411
+ For a pathname not ending in `/`, the digest input is the final raw pathname
412
+ segment followed by the serialized query including `?`:
413
+
414
+ ```text
415
+ https://example.com/path/to.php?id=123
416
+ MD5 input: to.php?id=123
417
+ -> example.com/path/<md5>.md
418
+ ```
419
+
420
+ For a pathname ending in `/`, the digest input is only the serialized query:
421
+
422
+ ```text
423
+ https://example.com/path/to/?id=123
424
+ MD5 input: ?id=123
425
+ -> example.com/path/to/<md5>.md
426
+ ```
427
+
428
+ The final directory segment uses the same recognized HTML-extension
429
+ normalization as queryless storage, so `/to/?id=123` and
430
+ `/to.html/?id=123` resolve to the same destination.
431
+
432
+ Root queries use `index` as their directory, so `/?id=123` and
433
+ `/index.html/?id=123` both resolve to `index/<md5>.md`.
434
+
435
+ If canonical selection or tracking cleanup removes the complete query, the
436
+ normal queryless destination rules apply.
357
437
 
358
438
  ### Safe path segments
359
439
 
@@ -410,14 +490,14 @@ Defuddle's exact whitespace.
410
490
 
411
491
  For ordinary links:
412
492
 
413
- - relative URLs are resolved against the final page URL
493
+ - relative URLs are resolved against the final response URL
414
494
  - absolute URLs are retained
415
495
  - fragment-only links are retained
416
496
  - reference-style link definitions are resolved and normalized
417
497
 
418
498
  For images:
419
499
 
420
- - relative destinations are resolved against the final page URL
500
+ - relative destinations are resolved against the final response URL
421
501
  - reference-style image definitions are resolved and localized
422
502
  - absolute image URLs are collected for asset localization
423
503
  - duplicate source URLs are fetched once
@@ -552,8 +632,9 @@ Metadata fields are emitted when non-empty:
552
632
 
553
633
  mdhq-controlled fields:
554
634
 
555
- - `source`: final page URL
556
- - `requested_url`: original URL, only when different from `source`
635
+ - `source`: normalized source URL
636
+ - `requested_url`: fragment-free serialized original URL, only when different
637
+ from `source`
557
638
  - `created`: initial local acquisition time
558
639
  - `modified`: time of the latest meaningful Markdown note change
559
640
  - `etag`: HTTP ETag stored verbatim, when supplied
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@songmu/mdhq",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Save web pages as Markdown in a ghq-inspired filesystem layout.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,6 +61,7 @@
61
61
  "temml": "^0.13.5",
62
62
  "turndown": "^7.2.0",
63
63
  "undici": "^8.10.0",
64
+ "urlpurify": "1.4.0",
64
65
  "yaml": "^2.8.1",
65
66
  "zod": "^4.5.2"
66
67
  },