@llm-newsletter-kit/core 1.1.4 → 1.1.6

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/dist/index.cjs CHANGED
@@ -838,8 +838,14 @@ class AnalysisChain extends Chain {
838
838
  }
839
839
  }
840
840
 
841
+ function preprocessBoldSyntax(markdown) {
842
+ // Convert **text** to <strong>text</strong> before marked parsing
843
+ // This fixes issues where marked doesn't properly handle bold syntax with parentheses
844
+ return markdown.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
845
+ }
841
846
  function markdownToHtml(markdown) {
842
- const html = marked.marked.parse(markdown);
847
+ const preprocessed = preprocessBoldSyntax(markdown);
848
+ const html = marked.marked.parse(preprocessed);
843
849
  const window = new jsdom.JSDOM('').window;
844
850
  const purify = DOMPurify(window);
845
851
  const sanitized = purify.sanitize(html);
@@ -849,22 +855,9 @@ function markdownToHtml(markdown) {
849
855
  return correctUnconvertedBoldSyntax(withDelReplaced);
850
856
  }
851
857
  function addTargetBlankToAnchors(htmlString) {
852
- // Regular expression to find '<a>' tags
853
- // This regex matches '<a>' tags that contain 'href' attribute and optionally other attributes
854
- // Excludes 'target="[^"]*"' to check if target attribute already exists
855
- const regex = /<a(\s+[^>]*?)?(?<!target="[^"]*")>/gi;
856
- // Use regex to find '<a>' tags and add 'target="_blank"'
857
- return htmlString.replace(regex, (_match, attributes) => {
858
- // Handle undefined attributes as empty string
859
- const currentAttributes = attributes || '';
860
- // Double check if target attribute exists (safety check for regex limitations)
861
- if (currentAttributes.includes('target=')) {
862
- return `<a${currentAttributes}>`; // If target attribute exists, return without modification
863
- }
864
- else {
865
- // Add target="_blank" attribute
866
- return `<a${currentAttributes} target="_blank">`;
867
- }
858
+ // DOMPurify removes target attributes, so we can safely add target="_blank" to all anchors
859
+ return htmlString.replace(/<a\s+([^>]*)>/gi, (_match, attributes) => {
860
+ return `<a ${attributes} target="_blank">`;
868
861
  });
869
862
  }
870
863
  function replaceDelTagsWithTilde(htmlString) {
@@ -877,19 +870,20 @@ function correctUnconvertedBoldSyntax(htmlString) {
877
870
  return htmlString.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
878
871
  }
879
872
  function correctMalformedUrls(htmlString) {
880
- // Pattern matches anchors with `)` followed by URL-encoded characters
873
+ // Pattern matches anchors with `)` followed by optional closing markup (</b> or **) and URL-encoded characters
881
874
  // Capture groups:
882
875
  // 1: attributes before href
883
876
  // 2: URL base (before `)`)
884
- // 3: URL-encoded part (starts with %)
885
- // 4: attributes after href
886
- // 5: link text base (before `)`)
887
- // 6: decoded text after `)` in link text
888
- const regex = /<a\s+([^>]*?)href="([^"]*?)\)(%[0-9A-Fa-f]{2}[^"]*?)"([^>]*?)>([^<]*?)\)([^<]*?)<\/a>/g;
889
- return htmlString.replace(regex, (_match, beforeHref, urlBase, _encodedPart, afterHref, textBase, decodedTextInLink) => {
890
- // The decoded text is already in the link text (decodedTextInLink)
891
- // We just need to move it outside the anchor along with the `)`
892
- return `<a ${beforeHref}href="${urlBase}"${afterHref}>${textBase}</a>)${decodedTextInLink}`;
877
+ // 3: closing markup (</b> or ** or empty)
878
+ // 4: URL-encoded part (starts with %)
879
+ // 5: attributes after href
880
+ // 6: link text base (before `)`)
881
+ // 7: text after `)` in link text (may include **)
882
+ const regex = /<a\s+([^>]*?)href="([^"]*?)\)((?:<\/b>|\*\*)?)(%[0-9A-Fa-f]{2}[^"]*?)"([^>]*?)>([^<]*?)\)((?:\*\*)?[^<]*?)<\/a>/g;
883
+ return htmlString.replace(regex, (_match, beforeHref, urlBase, closingMarkup, _encodedPart, afterHref, textBase, textAfterClosingParen) => {
884
+ // Remove leading ** from textAfterClosingParen since it's already captured as closingMarkup (</b>)
885
+ const cleanedText = textAfterClosingParen.replace(/^\*\*/, '');
886
+ return `<a ${beforeHref}href="${urlBase}"${afterHref}>${textBase}</a>${closingMarkup})${cleanedText}`;
893
887
  });
894
888
  }
895
889
 
@@ -1397,6 +1391,7 @@ async function getHtmlFromUrl(logger, url, referer = 'https://www.google.com/')
1397
1391
  throw lastError;
1398
1392
  }
1399
1393
 
1394
+ const toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
1400
1395
  class CrawlingChain extends Chain {
1401
1396
  constructor(config) {
1402
1397
  const provider = config.provider;
@@ -1426,17 +1421,15 @@ class CrawlingChain extends Chain {
1426
1421
  target: ({ target }) => target,
1427
1422
  })
1428
1423
  .pipe({
1429
- detailPagesHtmlWithPipelineId: ({ target, list }) => this.fetchDetailPagesHtml(target, list),
1424
+ detailFetchResult: ({ target, list }) => this.fetchDetailPagesHtml(target, list),
1430
1425
  target: ({ target }) => target,
1431
- list: ({ list }) => list,
1432
1426
  })
1433
1427
  .pipe({
1434
- parsedDetails: ({ target, detailPagesHtmlWithPipelineId }) => this.parseDetailPagesHtml(target, detailPagesHtmlWithPipelineId),
1428
+ detailParseResult: ({ target, detailFetchResult }) => this.parseDetailPagesHtml(target, detailFetchResult.list, detailFetchResult.detailPagesHtmlWithPipelineId),
1435
1429
  target: ({ target }) => target,
1436
- list: ({ list }) => list,
1437
1430
  })
1438
1431
  .pipe({
1439
- processedArticles: ({ target, list, parsedDetails }) => this.mergeParsedArticles(target, list, parsedDetails),
1432
+ processedArticles: ({ target, detailParseResult }) => this.mergeParsedArticles(target, detailParseResult.list, detailParseResult.parsedDetails),
1440
1433
  target: ({ target }) => target,
1441
1434
  })
1442
1435
  .pipe({
@@ -1464,7 +1457,21 @@ class CrawlingChain extends Chain {
1464
1457
  level: 'debug',
1465
1458
  startFields: { target: this.describeTarget(target) },
1466
1459
  }, async () => {
1467
- return await getHtmlFromUrl(this.logger, target.url);
1460
+ try {
1461
+ return await getHtmlFromUrl(this.logger, target.url);
1462
+ }
1463
+ catch (error) {
1464
+ this.logger.error({
1465
+ event: 'crawl.list.fetch.failed',
1466
+ taskId: this.taskId,
1467
+ data: {
1468
+ target: this.describeTarget(target),
1469
+ url: target.url,
1470
+ error: toErrorMessage(error),
1471
+ },
1472
+ });
1473
+ return '';
1474
+ }
1468
1475
  });
1469
1476
  }
1470
1477
  async parseListPageHtml(target, listPageHtml) {
@@ -1477,10 +1484,26 @@ class CrawlingChain extends Chain {
1477
1484
  },
1478
1485
  doneFields: (items) => ({ count: items.length }),
1479
1486
  }, async () => {
1480
- return (await target.parseList(listPageHtml)).map((item) => ({
1481
- ...item,
1482
- pipelineId: node_crypto.randomUUID(),
1483
- }));
1487
+ if (listPageHtml.length === 0) {
1488
+ return [];
1489
+ }
1490
+ try {
1491
+ return (await target.parseList(listPageHtml)).map((item) => ({
1492
+ ...item,
1493
+ pipelineId: node_crypto.randomUUID(),
1494
+ }));
1495
+ }
1496
+ catch (error) {
1497
+ this.logger.error({
1498
+ event: 'crawl.list.parse.failed',
1499
+ taskId: this.taskId,
1500
+ data: {
1501
+ target: this.describeTarget(target),
1502
+ error: toErrorMessage(error),
1503
+ },
1504
+ });
1505
+ return [];
1506
+ }
1484
1507
  });
1485
1508
  }
1486
1509
  async dedupeListItems(target, parsedList) {
@@ -1509,16 +1532,44 @@ class CrawlingChain extends Chain {
1509
1532
  target: this.describeTarget(target),
1510
1533
  count: list.length,
1511
1534
  },
1512
- doneFields: (htmlList) => ({ count: htmlList.length }),
1535
+ doneFields: (result) => ({
1536
+ successCount: result.detailPagesHtmlWithPipelineId.length,
1537
+ failedCount: result.failedCount,
1538
+ }),
1513
1539
  }, async () => {
1514
- const htmlList = await Promise.all(list.map((data) => getHtmlFromUrl(this.logger, data.detailUrl)));
1515
- return htmlList.map((html, index) => ({
1516
- pipelineId: list[index].pipelineId,
1517
- html,
1518
- }));
1540
+ const settled = await Promise.allSettled(list.map((data) => getHtmlFromUrl(this.logger, data.detailUrl)));
1541
+ const detailPagesHtmlWithPipelineId = [];
1542
+ const successList = [];
1543
+ let failedCount = 0;
1544
+ settled.forEach((result, index) => {
1545
+ const item = list[index];
1546
+ if (result.status === 'fulfilled') {
1547
+ detailPagesHtmlWithPipelineId.push({
1548
+ pipelineId: item.pipelineId,
1549
+ html: result.value,
1550
+ });
1551
+ successList.push(item);
1552
+ return;
1553
+ }
1554
+ failedCount += 1;
1555
+ this.logger.error({
1556
+ event: 'crawl.detail.fetch.failed',
1557
+ taskId: this.taskId,
1558
+ data: {
1559
+ target: this.describeTarget(target),
1560
+ detailUrl: item.detailUrl,
1561
+ error: toErrorMessage(result.reason),
1562
+ },
1563
+ });
1564
+ });
1565
+ return {
1566
+ list: successList,
1567
+ detailPagesHtmlWithPipelineId,
1568
+ failedCount,
1569
+ };
1519
1570
  });
1520
1571
  }
1521
- async parseDetailPagesHtml(target, detailPagesHtmlWithPipelineId) {
1572
+ async parseDetailPagesHtml(target, list, detailPagesHtmlWithPipelineId) {
1522
1573
  return this.executeWithLogging({
1523
1574
  event: 'crawl.detail.parse',
1524
1575
  level: 'debug',
@@ -1526,13 +1577,46 @@ class CrawlingChain extends Chain {
1526
1577
  target: this.describeTarget(target),
1527
1578
  count: detailPagesHtmlWithPipelineId.length,
1528
1579
  },
1529
- doneFields: (details) => ({ count: details.length }),
1580
+ doneFields: (result) => ({
1581
+ successCount: result.parsedDetails.length,
1582
+ failedCount: result.failedCount,
1583
+ }),
1530
1584
  }, async () => {
1531
- const detail = await Promise.all(detailPagesHtmlWithPipelineId.map(({ html }) => target.parseDetail(html)));
1532
- return detail.map((detail, index) => ({
1533
- pipelineId: detailPagesHtmlWithPipelineId[index].pipelineId,
1534
- ...detail,
1535
- }));
1585
+ const listItemMap = new Map(list.map((item) => [item.pipelineId, item]));
1586
+ const settled = await Promise.allSettled(detailPagesHtmlWithPipelineId.map(({ html }) => target.parseDetail(html)));
1587
+ const parsedDetails = [];
1588
+ const successList = [];
1589
+ let failedCount = 0;
1590
+ settled.forEach((result, index) => {
1591
+ const htmlItem = detailPagesHtmlWithPipelineId[index];
1592
+ const listItem = listItemMap.get(htmlItem.pipelineId);
1593
+ if (result.status === 'fulfilled' && listItem) {
1594
+ parsedDetails.push({
1595
+ pipelineId: htmlItem.pipelineId,
1596
+ ...result.value,
1597
+ });
1598
+ successList.push(listItem);
1599
+ return;
1600
+ }
1601
+ failedCount += 1;
1602
+ this.logger.error({
1603
+ event: 'crawl.detail.parse.failed',
1604
+ taskId: this.taskId,
1605
+ data: {
1606
+ target: this.describeTarget(target),
1607
+ detailUrl: listItem?.detailUrl,
1608
+ pipelineId: htmlItem.pipelineId,
1609
+ error: result.status === 'rejected'
1610
+ ? toErrorMessage(result.reason)
1611
+ : 'Missing list item for parsed detail',
1612
+ },
1613
+ });
1614
+ });
1615
+ return {
1616
+ list: successList,
1617
+ parsedDetails,
1618
+ failedCount,
1619
+ };
1536
1620
  });
1537
1621
  }
1538
1622
  // Although this is a synchronous method, using async wrapping to maintain consistency with the executeWithLogging interface
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
package/dist/index.js CHANGED
@@ -836,8 +836,14 @@ class AnalysisChain extends Chain {
836
836
  }
837
837
  }
838
838
 
839
+ function preprocessBoldSyntax(markdown) {
840
+ // Convert **text** to <strong>text</strong> before marked parsing
841
+ // This fixes issues where marked doesn't properly handle bold syntax with parentheses
842
+ return markdown.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
843
+ }
839
844
  function markdownToHtml(markdown) {
840
- const html = marked.parse(markdown);
845
+ const preprocessed = preprocessBoldSyntax(markdown);
846
+ const html = marked.parse(preprocessed);
841
847
  const window = new JSDOM('').window;
842
848
  const purify = DOMPurify(window);
843
849
  const sanitized = purify.sanitize(html);
@@ -847,22 +853,9 @@ function markdownToHtml(markdown) {
847
853
  return correctUnconvertedBoldSyntax(withDelReplaced);
848
854
  }
849
855
  function addTargetBlankToAnchors(htmlString) {
850
- // Regular expression to find '<a>' tags
851
- // This regex matches '<a>' tags that contain 'href' attribute and optionally other attributes
852
- // Excludes 'target="[^"]*"' to check if target attribute already exists
853
- const regex = /<a(\s+[^>]*?)?(?<!target="[^"]*")>/gi;
854
- // Use regex to find '<a>' tags and add 'target="_blank"'
855
- return htmlString.replace(regex, (_match, attributes) => {
856
- // Handle undefined attributes as empty string
857
- const currentAttributes = attributes || '';
858
- // Double check if target attribute exists (safety check for regex limitations)
859
- if (currentAttributes.includes('target=')) {
860
- return `<a${currentAttributes}>`; // If target attribute exists, return without modification
861
- }
862
- else {
863
- // Add target="_blank" attribute
864
- return `<a${currentAttributes} target="_blank">`;
865
- }
856
+ // DOMPurify removes target attributes, so we can safely add target="_blank" to all anchors
857
+ return htmlString.replace(/<a\s+([^>]*)>/gi, (_match, attributes) => {
858
+ return `<a ${attributes} target="_blank">`;
866
859
  });
867
860
  }
868
861
  function replaceDelTagsWithTilde(htmlString) {
@@ -875,19 +868,20 @@ function correctUnconvertedBoldSyntax(htmlString) {
875
868
  return htmlString.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
876
869
  }
877
870
  function correctMalformedUrls(htmlString) {
878
- // Pattern matches anchors with `)` followed by URL-encoded characters
871
+ // Pattern matches anchors with `)` followed by optional closing markup (</b> or **) and URL-encoded characters
879
872
  // Capture groups:
880
873
  // 1: attributes before href
881
874
  // 2: URL base (before `)`)
882
- // 3: URL-encoded part (starts with %)
883
- // 4: attributes after href
884
- // 5: link text base (before `)`)
885
- // 6: decoded text after `)` in link text
886
- const regex = /<a\s+([^>]*?)href="([^"]*?)\)(%[0-9A-Fa-f]{2}[^"]*?)"([^>]*?)>([^<]*?)\)([^<]*?)<\/a>/g;
887
- return htmlString.replace(regex, (_match, beforeHref, urlBase, _encodedPart, afterHref, textBase, decodedTextInLink) => {
888
- // The decoded text is already in the link text (decodedTextInLink)
889
- // We just need to move it outside the anchor along with the `)`
890
- return `<a ${beforeHref}href="${urlBase}"${afterHref}>${textBase}</a>)${decodedTextInLink}`;
875
+ // 3: closing markup (</b> or ** or empty)
876
+ // 4: URL-encoded part (starts with %)
877
+ // 5: attributes after href
878
+ // 6: link text base (before `)`)
879
+ // 7: text after `)` in link text (may include **)
880
+ const regex = /<a\s+([^>]*?)href="([^"]*?)\)((?:<\/b>|\*\*)?)(%[0-9A-Fa-f]{2}[^"]*?)"([^>]*?)>([^<]*?)\)((?:\*\*)?[^<]*?)<\/a>/g;
881
+ return htmlString.replace(regex, (_match, beforeHref, urlBase, closingMarkup, _encodedPart, afterHref, textBase, textAfterClosingParen) => {
882
+ // Remove leading ** from textAfterClosingParen since it's already captured as closingMarkup (</b>)
883
+ const cleanedText = textAfterClosingParen.replace(/^\*\*/, '');
884
+ return `<a ${beforeHref}href="${urlBase}"${afterHref}>${textBase}</a>${closingMarkup})${cleanedText}`;
891
885
  });
892
886
  }
893
887
 
@@ -1395,6 +1389,7 @@ async function getHtmlFromUrl(logger, url, referer = 'https://www.google.com/')
1395
1389
  throw lastError;
1396
1390
  }
1397
1391
 
1392
+ const toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
1398
1393
  class CrawlingChain extends Chain {
1399
1394
  constructor(config) {
1400
1395
  const provider = config.provider;
@@ -1424,17 +1419,15 @@ class CrawlingChain extends Chain {
1424
1419
  target: ({ target }) => target,
1425
1420
  })
1426
1421
  .pipe({
1427
- detailPagesHtmlWithPipelineId: ({ target, list }) => this.fetchDetailPagesHtml(target, list),
1422
+ detailFetchResult: ({ target, list }) => this.fetchDetailPagesHtml(target, list),
1428
1423
  target: ({ target }) => target,
1429
- list: ({ list }) => list,
1430
1424
  })
1431
1425
  .pipe({
1432
- parsedDetails: ({ target, detailPagesHtmlWithPipelineId }) => this.parseDetailPagesHtml(target, detailPagesHtmlWithPipelineId),
1426
+ detailParseResult: ({ target, detailFetchResult }) => this.parseDetailPagesHtml(target, detailFetchResult.list, detailFetchResult.detailPagesHtmlWithPipelineId),
1433
1427
  target: ({ target }) => target,
1434
- list: ({ list }) => list,
1435
1428
  })
1436
1429
  .pipe({
1437
- processedArticles: ({ target, list, parsedDetails }) => this.mergeParsedArticles(target, list, parsedDetails),
1430
+ processedArticles: ({ target, detailParseResult }) => this.mergeParsedArticles(target, detailParseResult.list, detailParseResult.parsedDetails),
1438
1431
  target: ({ target }) => target,
1439
1432
  })
1440
1433
  .pipe({
@@ -1462,7 +1455,21 @@ class CrawlingChain extends Chain {
1462
1455
  level: 'debug',
1463
1456
  startFields: { target: this.describeTarget(target) },
1464
1457
  }, async () => {
1465
- return await getHtmlFromUrl(this.logger, target.url);
1458
+ try {
1459
+ return await getHtmlFromUrl(this.logger, target.url);
1460
+ }
1461
+ catch (error) {
1462
+ this.logger.error({
1463
+ event: 'crawl.list.fetch.failed',
1464
+ taskId: this.taskId,
1465
+ data: {
1466
+ target: this.describeTarget(target),
1467
+ url: target.url,
1468
+ error: toErrorMessage(error),
1469
+ },
1470
+ });
1471
+ return '';
1472
+ }
1466
1473
  });
1467
1474
  }
1468
1475
  async parseListPageHtml(target, listPageHtml) {
@@ -1475,10 +1482,26 @@ class CrawlingChain extends Chain {
1475
1482
  },
1476
1483
  doneFields: (items) => ({ count: items.length }),
1477
1484
  }, async () => {
1478
- return (await target.parseList(listPageHtml)).map((item) => ({
1479
- ...item,
1480
- pipelineId: randomUUID(),
1481
- }));
1485
+ if (listPageHtml.length === 0) {
1486
+ return [];
1487
+ }
1488
+ try {
1489
+ return (await target.parseList(listPageHtml)).map((item) => ({
1490
+ ...item,
1491
+ pipelineId: randomUUID(),
1492
+ }));
1493
+ }
1494
+ catch (error) {
1495
+ this.logger.error({
1496
+ event: 'crawl.list.parse.failed',
1497
+ taskId: this.taskId,
1498
+ data: {
1499
+ target: this.describeTarget(target),
1500
+ error: toErrorMessage(error),
1501
+ },
1502
+ });
1503
+ return [];
1504
+ }
1482
1505
  });
1483
1506
  }
1484
1507
  async dedupeListItems(target, parsedList) {
@@ -1507,16 +1530,44 @@ class CrawlingChain extends Chain {
1507
1530
  target: this.describeTarget(target),
1508
1531
  count: list.length,
1509
1532
  },
1510
- doneFields: (htmlList) => ({ count: htmlList.length }),
1533
+ doneFields: (result) => ({
1534
+ successCount: result.detailPagesHtmlWithPipelineId.length,
1535
+ failedCount: result.failedCount,
1536
+ }),
1511
1537
  }, async () => {
1512
- const htmlList = await Promise.all(list.map((data) => getHtmlFromUrl(this.logger, data.detailUrl)));
1513
- return htmlList.map((html, index) => ({
1514
- pipelineId: list[index].pipelineId,
1515
- html,
1516
- }));
1538
+ const settled = await Promise.allSettled(list.map((data) => getHtmlFromUrl(this.logger, data.detailUrl)));
1539
+ const detailPagesHtmlWithPipelineId = [];
1540
+ const successList = [];
1541
+ let failedCount = 0;
1542
+ settled.forEach((result, index) => {
1543
+ const item = list[index];
1544
+ if (result.status === 'fulfilled') {
1545
+ detailPagesHtmlWithPipelineId.push({
1546
+ pipelineId: item.pipelineId,
1547
+ html: result.value,
1548
+ });
1549
+ successList.push(item);
1550
+ return;
1551
+ }
1552
+ failedCount += 1;
1553
+ this.logger.error({
1554
+ event: 'crawl.detail.fetch.failed',
1555
+ taskId: this.taskId,
1556
+ data: {
1557
+ target: this.describeTarget(target),
1558
+ detailUrl: item.detailUrl,
1559
+ error: toErrorMessage(result.reason),
1560
+ },
1561
+ });
1562
+ });
1563
+ return {
1564
+ list: successList,
1565
+ detailPagesHtmlWithPipelineId,
1566
+ failedCount,
1567
+ };
1517
1568
  });
1518
1569
  }
1519
- async parseDetailPagesHtml(target, detailPagesHtmlWithPipelineId) {
1570
+ async parseDetailPagesHtml(target, list, detailPagesHtmlWithPipelineId) {
1520
1571
  return this.executeWithLogging({
1521
1572
  event: 'crawl.detail.parse',
1522
1573
  level: 'debug',
@@ -1524,13 +1575,46 @@ class CrawlingChain extends Chain {
1524
1575
  target: this.describeTarget(target),
1525
1576
  count: detailPagesHtmlWithPipelineId.length,
1526
1577
  },
1527
- doneFields: (details) => ({ count: details.length }),
1578
+ doneFields: (result) => ({
1579
+ successCount: result.parsedDetails.length,
1580
+ failedCount: result.failedCount,
1581
+ }),
1528
1582
  }, async () => {
1529
- const detail = await Promise.all(detailPagesHtmlWithPipelineId.map(({ html }) => target.parseDetail(html)));
1530
- return detail.map((detail, index) => ({
1531
- pipelineId: detailPagesHtmlWithPipelineId[index].pipelineId,
1532
- ...detail,
1533
- }));
1583
+ const listItemMap = new Map(list.map((item) => [item.pipelineId, item]));
1584
+ const settled = await Promise.allSettled(detailPagesHtmlWithPipelineId.map(({ html }) => target.parseDetail(html)));
1585
+ const parsedDetails = [];
1586
+ const successList = [];
1587
+ let failedCount = 0;
1588
+ settled.forEach((result, index) => {
1589
+ const htmlItem = detailPagesHtmlWithPipelineId[index];
1590
+ const listItem = listItemMap.get(htmlItem.pipelineId);
1591
+ if (result.status === 'fulfilled' && listItem) {
1592
+ parsedDetails.push({
1593
+ pipelineId: htmlItem.pipelineId,
1594
+ ...result.value,
1595
+ });
1596
+ successList.push(listItem);
1597
+ return;
1598
+ }
1599
+ failedCount += 1;
1600
+ this.logger.error({
1601
+ event: 'crawl.detail.parse.failed',
1602
+ taskId: this.taskId,
1603
+ data: {
1604
+ target: this.describeTarget(target),
1605
+ detailUrl: listItem?.detailUrl,
1606
+ pipelineId: htmlItem.pipelineId,
1607
+ error: result.status === 'rejected'
1608
+ ? toErrorMessage(result.reason)
1609
+ : 'Missing list item for parsed detail',
1610
+ },
1611
+ });
1612
+ });
1613
+ return {
1614
+ list: successList,
1615
+ parsedDetails,
1616
+ failedCount,
1617
+ };
1534
1618
  });
1535
1619
  }
1536
1620
  // Although this is a synchronous method, using async wrapping to maintain consistency with the executeWithLogging interface
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@llm-newsletter-kit/core",
3
3
  "private": false,
4
4
  "type": "module",
5
- "version": "1.1.4",
5
+ "version": "1.1.6",
6
6
  "description": "An extensible framework to automate your entire newsletter workflow. Handles data collection, LLM-based content analysis, and email generation, letting you focus on your unique domain logic.",
7
7
  "main": "dist/index.cjs",
8
8
  "module": "dist/index.js",
@@ -46,8 +46,8 @@
46
46
  "author": "kimhongyeon",
47
47
  "license": "Apache-2.0",
48
48
  "dependencies": {
49
- "@langchain/core": "^1.1.18",
50
- "ai": "^6.0.66",
49
+ "@langchain/core": "^1.1.19",
50
+ "ai": "^6.0.69",
51
51
  "dompurify": "^3.3.1",
52
52
  "es-toolkit": "^1.44.0",
53
53
  "jsdom": "^27.4.0",
@@ -59,7 +59,7 @@
59
59
  "@eslint/js": "^9.39.2",
60
60
  "@trivago/prettier-plugin-sort-imports": "^6.0.2",
61
61
  "@types/jsdom": "^27.0.0",
62
- "@types/node": "^25.1.0",
62
+ "@types/node": "^25.2.0",
63
63
  "@vitest/coverage-v8": "^3.2.4",
64
64
  "@vitest/expect": "^3.2.4",
65
65
  "eslint": "^9.39.2",