@@ -25,58 +25,75 @@ public async Task<string> GetReleaseNotesAsync(string packageId, string version)
2525 {
2626 try
2727 {
28- var url = $ "{ _baseUrl } /{ PackageUrlEncoder . EscapeSegment ( packageId ) } /changelog";
29- var html = await _httpClient . GetStringAsync ( url ) ;
30- var doc = new HtmlDocument ( ) ;
31- doc . LoadHtml ( html ) ;
28+ var packageUrl = $ "{ _baseUrl } /{ PackageUrlEncoder . EscapeSegment ( packageId ) } ";
29+ // The package's changelog page shows only the latest upload; each version's page keeps the file it shipped with
30+ return await ReadChangelogEntryAsync ( $ "{ packageUrl } /versions/{ PackageUrlEncoder . EscapeSegment ( version ) } /changelog", version )
31+ ?? await ReadChangelogEntryAsync ( $ "{ packageUrl } /changelog", version )
32+ ?? string . Empty ;
33+ }
34+ catch ( Exception ex )
35+ {
36+ _logger . LogError ( ex , "Error fetching release notes from Pub.dev for package {PackageId} version {Version}" , packageId , version ) ;
37+ return string . Empty ;
38+ }
39+ }
3240
33- var changelogNodes = doc . DocumentNode . SelectNodes ( "//div[@class='changelog-entry']" ) ;
34- if ( changelogNodes == null || ! changelogNodes . Any ( ) )
35- return string . Empty ;
41+ // Null when the page or its entry for the version is missing
42+ private async Task < string ? > ReadChangelogEntryAsync ( string url , string version )
43+ {
44+ using var response = await _httpClient . GetAsync ( url ) ;
45+ if ( ! response . IsSuccessStatusCode )
46+ return null ;
3647
37- var versionId = version . Replace ( "." , "" ) ; // pub.dev changelog anchors drop the dots
48+ var doc = new HtmlDocument ( ) ;
49+ doc . LoadHtml ( await response . Content . ReadAsStringAsync ( ) ) ;
3850
39- foreach ( var node in changelogNodes )
40- {
41- var headerNode = node . SelectSingleNode ( ".//h2" ) ;
42- if ( headerNode != null && headerNode . InnerText . Trim ( ) . StartsWith ( version ) )
43- {
44- var contentNode = node . SelectSingleNode ( ".//div[@class='changelog-content']" ) ;
45- if ( contentNode == null ) return string . Empty ;
51+ var changelogNodes = doc . DocumentNode . SelectNodes ( "//div[@class='changelog-entry']" ) ;
52+ if ( changelogNodes == null )
53+ return null ;
4654
47- var rawHtml = contentNode . InnerHtml ;
55+ foreach ( var node in changelogNodes )
56+ {
57+ var headerNode = node . SelectSingleNode ( ".//h2" ) ;
58+ if ( headerNode == null || ! IsHeaderOf ( headerNode . InnerText , version ) )
59+ continue ;
4860
49- rawHtml = Regex . Replace ( rawHtml , "<br[^>]*>" , "\n " , RegexOptions . IgnoreCase ) ;
50- rawHtml = Regex . Replace ( rawHtml , "</p>" , "\n \n " , RegexOptions . IgnoreCase ) ;
51- rawHtml = Regex . Replace ( rawHtml , "<li[^>]*>" , "\n - " , RegexOptions . IgnoreCase ) ;
52- rawHtml = Regex . Replace ( rawHtml , "<h[1-6][^>]*>" , "\n # " , RegexOptions . IgnoreCase ) ;
61+ var contentNode = node . SelectSingleNode ( ".//div[@class='changelog-content']" ) ;
62+ if ( contentNode == null ) return string . Empty ;
5363
54- rawHtml = Regex . Replace ( rawHtml , "<strong[^>]*>(.*?)</strong>" , "**$1**" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
55- rawHtml = Regex . Replace ( rawHtml , "<b[^>]*>(.*?)</b>" , "**$1**" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
56- rawHtml = Regex . Replace ( rawHtml , "<em[^>]*>(.*?)</em>" , "*$1*" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
57- rawHtml = Regex . Replace ( rawHtml , "<i[^>]*>(.*?)</i>" , "*$1*" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
58- rawHtml = Regex . Replace ( rawHtml , "<code[^>]*>(.*?)</code>" , "`$1`" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
59- rawHtml = Regex . Replace ( rawHtml , "<pre[^>]*>(.*?)</pre>" , "\n ```\n $1\n ```\n " , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
64+ var rawHtml = contentNode . InnerHtml ;
6065
61- rawHtml = Regex . Replace ( rawHtml , "<a[^>]+href=\" ([^\" ]+)\" [^>]*>(.*?)</a>" , "[$2]($1)" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
66+ rawHtml = Regex . Replace ( rawHtml , "<br[^>]*>" , "\n " , RegexOptions . IgnoreCase ) ;
67+ rawHtml = Regex . Replace ( rawHtml , "</p>" , "\n \n " , RegexOptions . IgnoreCase ) ;
68+ rawHtml = Regex . Replace ( rawHtml , "<li[^>]*>" , "\n - " , RegexOptions . IgnoreCase ) ;
69+ rawHtml = Regex . Replace ( rawHtml , "<h[1-6][^>]*>" , "\n # " , RegexOptions . IgnoreCase ) ;
6270
63- var tempDoc = new HtmlDocument ( ) ;
64- tempDoc . LoadHtml ( rawHtml ) ;
65- var plainText = tempDoc . DocumentNode . InnerText ;
71+ rawHtml = Regex . Replace ( rawHtml , "<strong[^>]*>(.*?)</strong>" , "**$1**" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
72+ rawHtml = Regex . Replace ( rawHtml , "<b[^>]*>(.*?)</b>" , "**$1**" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
73+ rawHtml = Regex . Replace ( rawHtml , "<em[^>]*>(.*?)</em>" , "*$1*" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
74+ rawHtml = Regex . Replace ( rawHtml , "<i[^>]*>(.*?)</i>" , "*$1*" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
75+ rawHtml = Regex . Replace ( rawHtml , "<code[^>]*>(.*?)</code>" , "`$1`" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
76+ rawHtml = Regex . Replace ( rawHtml , "<pre[^>]*>(.*?)</pre>" , "\n ```\n $1\n ```\n " , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
6677
67- plainText = Regex . Replace ( plainText , @"\n{3,}" , "\n \n " ) ;
78+ rawHtml = Regex . Replace ( rawHtml , "<a[^>]+href= \" ([^ \" ]+) \" [^>]*>(.*?)</a>" , "[$2]($1)" , RegexOptions . IgnoreCase | RegexOptions . Singleline ) ;
6879
69- return plainText . Trim ( ) ;
70- }
71- }
80+ var tempDoc = new HtmlDocument ( ) ;
81+ tempDoc . LoadHtml ( rawHtml ) ;
82+ var plainText = tempDoc . DocumentNode . InnerText ;
7283
73- return string . Empty ;
74- }
75- catch ( Exception ex )
76- {
77- _logger . LogError ( ex , "Error fetching release notes from Pub.dev for package {PackageId} version {Version}" , packageId , version ) ;
78- return string . Empty ;
84+ plainText = Regex . Replace ( plainText , @"\n{3,}" , "\n \n " ) ;
85+
86+ return plainText . Trim ( ) ;
7987 }
88+
89+ return null ;
90+ }
91+
92+ private static bool IsHeaderOf ( string headerText , string version )
93+ {
94+ var firstWord = headerText . Split ( ( char [ ] ? ) null , 2 , StringSplitOptions . RemoveEmptyEntries ) . FirstOrDefault ( ) ?? string . Empty ;
95+ return firstWord . Trim ( '[' , ']' , '(' , ')' , ':' , ',' ) . TrimStart ( 'v' , 'V' )
96+ . Equals ( version , StringComparison . OrdinalIgnoreCase ) ;
8097 }
8198
8299 public async Task < string ? > GetGitHubRepositoryUrlAsync ( string packageId )
0 commit comments