Repository navigation
Expand file tree
/
Copy pathProgram.cs
More file actions
570 lines (484 loc) · 16.9 KB
/
Copy pathProgram.cs
File metadata and controls
570 lines (484 loc) · 16.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using CodeHollow.FeedReader;
using Microsoft.Playwright;
using Octokit;
using var httpClient = new HttpClient();
await using var app = await FeedSyncApplication.CreateAsync(httpClient);
await app.RunAsync();
internal sealed class FeedSyncApplication : IAsyncDisposable
{
private readonly GitHubIssueService _issueService;
private readonly BlogContentFetcher _contentFetcher;
private readonly OpenAiSummaryService _summaryService;
private readonly string _feedSourcePath;
private FeedSyncApplication(
GitHubIssueService issueService,
BlogContentFetcher contentFetcher,
OpenAiSummaryService summaryService,
string feedSourcePath)
{
_issueService = issueService;
_contentFetcher = contentFetcher;
_summaryService = summaryService;
_feedSourcePath = feedSourcePath;
}
public static async Task<FeedSyncApplication> CreateAsync(HttpClient httpClient)
{
var repoRoot = RepositoryPaths.FindRepositoryRoot();
var feedSourcePath = Path.Combine(repoRoot, "feedsource.txt");
var repository = GitHubRepository.FromEnvironment(AppDefaults.DefaultOwner, AppDefaults.DefaultRepository);
var issueService = GitHubIssueService.Create(repository);
var contentFetcher = await BlogContentFetcher.CreateAsync();
var summaryService = OpenAiSummaryService.Create(httpClient);
return new FeedSyncApplication(issueService, contentFetcher, summaryService, feedSourcePath);
}
public async Task RunAsync(CancellationToken cancellationToken = default)
{
var targetIssue = await _issueService.GetLatestOpenIssueAsync(cancellationToken);
var lastUpdate = await _issueService.GetLastUpdateAsync(targetIssue, cancellationToken);
Console.WriteLine($"Target issue: #{targetIssue.Number} ({targetIssue.Title})");
Console.WriteLine($"Last update (UTC): {lastUpdate:O}");
var seenLinks = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var urls = await File.ReadAllLinesAsync(_feedSourcePath, cancellationToken);
foreach (var url in urls)
{
if (string.IsNullOrWhiteSpace(url))
{
continue;
}
Console.WriteLine($"Reading feed: {url}");
CodeHollow.FeedReader.Feed feed;
try
{
feed = await FeedReader.ReadAsync(url);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to read feed {url}: {ex.Message}");
continue;
}
foreach (var item in feed.Items)
{
if (string.IsNullOrWhiteSpace(item.Link))
{
continue;
}
var link = item.Link.Trim();
if (!seenLinks.Add(link))
{
continue;
}
var publishedAt = NormalizeToUtc(item.PublishingDate) ?? NormalizeToUtc(item.PublishingDateString);
if (publishedAt is null || publishedAt <= lastUpdate)
{
continue;
}
var title = string.IsNullOrWhiteSpace(item.Title) ? link : item.Title.Trim();
var summary = AppDefaults.SummaryFailedMessage;
try
{
Console.WriteLine($"Fetching content: {link}");
var blogData = await _contentFetcher.FetchAsync(link, cancellationToken);
if (!string.IsNullOrWhiteSpace(blogData.Error))
{
Console.WriteLine($"fetch-content error: {blogData.Error}");
}
if (!string.IsNullOrWhiteSpace(blogData.Content))
{
var fetchedTitle = string.IsNullOrWhiteSpace(blogData.Title) ? title : blogData.Title;
summary = await _summaryService.GetSummaryAsync(link, fetchedTitle, blogData.Content, cancellationToken);
if (string.IsNullOrWhiteSpace(summary))
{
summary = AppDefaults.SummaryFailedMessage;
}
}
else
{
Console.WriteLine($"Failed to fetch content for {link}, skipping summarization.");
summary = AppDefaults.ContentFailedMessage;
}
}
catch (Exception ex)
{
Console.WriteLine($"Summarization pipeline error for {link}: {ex.Message}");
summary = AppDefaults.PipelineFailedMessage;
}
finally
{
var comment = $"[{title}]({link}) {summary}";
Console.WriteLine($"Posting comment for article: {title}");
await _issueService.AddCommentAsync(targetIssue.Number, comment, cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(5d), cancellationToken);
}
}
}
}
public async ValueTask DisposeAsync()
{
await _contentFetcher.DisposeAsync();
_issueService.Dispose();
}
private static DateTimeOffset? NormalizeToUtc(DateTime? dateTime)
{
if (dateTime is null)
{
return null;
}
return dateTime.Value.Kind switch
{
DateTimeKind.Utc => new DateTimeOffset(dateTime.Value, TimeSpan.Zero),
DateTimeKind.Local => new DateTimeOffset(dateTime.Value.ToUniversalTime(), TimeSpan.Zero),
_ => new DateTimeOffset(DateTime.SpecifyKind(dateTime.Value, DateTimeKind.Utc), TimeSpan.Zero)
};
}
private static DateTimeOffset? NormalizeToUtc(string? dateText)
{
if (string.IsNullOrWhiteSpace(dateText))
{
return null;
}
return DateTimeOffset.TryParse(dateText, out var parsed)
? parsed.ToUniversalTime()
: null;
}
}
internal sealed class GitHubIssueService : IDisposable
{
private readonly GitHubClient _client;
private readonly GitHubRepository _repository;
private GitHubIssueService(GitHubClient client, GitHubRepository repository)
{
_client = client;
_repository = repository;
}
public static GitHubIssueService Create(GitHubRepository repository)
{
var token = Environment.GetEnvironmentVariable("GH_TOKEN");
if (string.IsNullOrWhiteSpace(token))
{
throw new InvalidOperationException("GH_TOKEN environment variable is required.");
}
var client = new GitHubClient(new Octokit.ProductHeaderValue("devblogradio-feed-sync"))
{
Credentials = new Credentials(token)
};
return new GitHubIssueService(client, repository);
}
public async Task<Issue> GetLatestOpenIssueAsync(CancellationToken cancellationToken)
{
var request = new RepositoryIssueRequest
{
State = ItemStateFilter.Open,
SortProperty = IssueSort.Created,
SortDirection = SortDirection.Descending
};
var options = new ApiOptions
{
PageCount = 1,
PageSize = 1,
StartPage = 1
};
var issues = await _client.Issue.GetAllForRepository(_repository.Owner, _repository.Name, request, options);
cancellationToken.ThrowIfCancellationRequested();
return issues.FirstOrDefault() ?? throw new InvalidOperationException("No open issue found.");
}
public async Task<DateTimeOffset> GetLastUpdateAsync(Issue issue, CancellationToken cancellationToken)
{
var comments = await _client.Issue.Comment.GetAllForIssue(_repository.Owner, _repository.Name, issue.Number);
cancellationToken.ThrowIfCancellationRequested();
var latestComment = comments
.OrderByDescending(comment => comment.CreatedAt)
.FirstOrDefault();
return latestComment?.CreatedAt.ToUniversalTime() ?? issue.CreatedAt.ToUniversalTime();
}
public async Task AddCommentAsync(int issueNumber, string body, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
await _client.Issue.Comment.Create(_repository.Owner, _repository.Name, issueNumber, body);
}
public void Dispose()
{
}
}
internal sealed class BlogContentFetcher : IAsyncDisposable
{
private readonly IPlaywright _playwright;
private readonly IBrowser _browser;
private BlogContentFetcher(IPlaywright playwright, IBrowser browser)
{
_playwright = playwright;
_browser = browser;
}
public static async Task<BlogContentFetcher> CreateAsync()
{
IPlaywright? playwright = null;
try
{
playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
return new BlogContentFetcher(playwright, browser);
}
catch (PlaywrightException ex)
{
playwright?.Dispose();
var message = ex.Message.Contains("Executable doesn't exist", StringComparison.OrdinalIgnoreCase)
? "Playwright Chromium is not installed. Run `playwright install --with-deps chromium` to install it."
: $"Playwright failed to start Chromium: {ex.Message}";
throw new InvalidOperationException(message, ex);
}
catch (Exception)
{
playwright?.Dispose();
throw;
}
}
public async Task<BlogContentResult> FetchAsync(string url, CancellationToken cancellationToken)
{
var page = await _browser.NewPageAsync();
try
{
await page.GotoAsync(url, new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
Timeout = 30000
});
cancellationToken.ThrowIfCancellationRequested();
var title = await page.TitleAsync();
await page.EvaluateAsync("""
() => {
const removeTargets = (root) => {
root.querySelectorAll('pre, code, img, figure, svg, script, style, video, iframe, table').forEach((element) => element.remove());
};
const article = document.querySelector('article[data-clarity-region="article"] div.entry-content');
if (article) {
removeTargets(article);
}
}
""");
var content = await page.EvaluateAsync<string>("""
() => {
const removeTargets = (root) => {
root.querySelectorAll('pre, code, img, figure, svg, script, style, video, iframe, table').forEach((element) => element.remove());
};
const article = document.querySelector('article[data-clarity-region="article"] div.entry-content');
if (article) {
return article.innerText || '';
}
const fallback = document.querySelector('article .entry-content') ||
document.querySelector('.entry-content') ||
document.querySelector('article');
if (fallback) {
removeTargets(fallback);
return fallback.innerText || '';
}
return '';
}
""");
var cleanContent = CollapseBlankLines(content);
return new BlogContentResult(title, cleanContent, string.Empty);
}
catch (Exception ex)
{
return new BlogContentResult(string.Empty, string.Empty, ex.Message);
}
finally
{
await page.CloseAsync();
}
}
public async ValueTask DisposeAsync()
{
await _browser.DisposeAsync();
_playwright.Dispose();
}
private static string CollapseBlankLines(string? content)
{
if (string.IsNullOrWhiteSpace(content))
{
return string.Empty;
}
var builder = new StringBuilder(content.Length);
var newlineCount = 0;
foreach (var ch in content.Replace("\r\n", "\n", StringComparison.Ordinal))
{
if (ch == '\n')
{
newlineCount++;
if (newlineCount <= 2)
{
builder.Append(ch);
}
continue;
}
newlineCount = 0;
builder.Append(ch);
}
return builder.ToString().Trim();
}
}
internal sealed class OpenAiSummaryService
{
private readonly HttpClient _httpClient;
private readonly string _token;
private readonly Uri _endpoint;
private readonly string _deployment;
private OpenAiSummaryService(HttpClient httpClient, string token, Uri endpoint, string deployment)
{
_httpClient = httpClient;
_token = token;
_endpoint = endpoint;
_deployment = deployment;
}
public static OpenAiSummaryService Create(HttpClient httpClient)
{
var token = Environment.GetEnvironmentVariable("OPENAI_API_TOKEN");
var openAiBase = Environment.GetEnvironmentVariable("OPENAI_API_BASE");
var deployment = Environment.GetEnvironmentVariable("OPENAI_API_DEPLOY");
if (string.IsNullOrWhiteSpace(token))
{
throw new InvalidOperationException("OPENAI_API_TOKEN environment variable is required.");
}
if (string.IsNullOrWhiteSpace(openAiBase))
{
throw new InvalidOperationException("OPENAI_API_BASE environment variable is required.");
}
if (string.IsNullOrWhiteSpace(deployment))
{
throw new InvalidOperationException("OPENAI_API_DEPLOY environment variable is required.");
}
var endpoint = new Uri($"https://{openAiBase}.cognitiveservices.azure.com/openai/v1/chat/completions");
return new OpenAiSummaryService(httpClient, token, endpoint, deployment);
}
public async Task<string> GetSummaryAsync(string blogUrl, string blogTitle, string blogContent, CancellationToken cancellationToken)
{
var truncatedContent = blogContent.Length > 8000
? blogContent[..8000] + "\n...(以下省略)"
: blogContent;
var prompt = $"""
以下のブログ記事を要約してください。本文が日本語以外である場合、日本語で300文字以内に要約してください。本文が英語で1000words以上ある場合は最初と最後の段落を忠実に日本語翻訳し、段落として記載してください。プログラムコードブロックがある場合、プログラムの解説も要約に含めてください。
タイトル: {blogTitle}
URL: {blogUrl}
本文:
{truncatedContent}
""";
var payload = new ChatCompletionRequest(
4000,
_deployment,
[new ChatMessage("user", prompt)]);
using var request = new HttpRequestMessage(HttpMethod.Post, _endpoint)
{
Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions.Default))
};
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("api-key", _token);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
try
{
using var response = await _httpClient.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
Console.Error.WriteLine(
$"OpenAI request failed. StatusCode={(int)response.StatusCode} ({response.StatusCode}), Reason={response.ReasonPhrase ?? "(none)"}, Endpoint={_endpoint}, Deployment={_deployment}");
Console.Error.WriteLine($"OpenAI error response: {TruncateForLog(responseBody, 4000)}");
return AppDefaults.SummaryFailedMessage;
}
var completion = JsonSerializer.Deserialize<ChatCompletionResponse>(responseBody, JsonOptions.Default);
var choice = completion?.Choices?.FirstOrDefault();
var message = choice?.Message?.Content;
if (string.IsNullOrWhiteSpace(message))
{
Console.Error.WriteLine(
$"OpenAI response did not contain a summary message. Endpoint={_endpoint}, Deployment={_deployment}, FinishReason={choice?.FinishReason ?? "(none)"}");
Console.Error.WriteLine($"OpenAI raw response: {TruncateForLog(responseBody, 4000)}");
return AppDefaults.SummaryFailedMessage;
}
return message.Trim();
}
catch (Exception ex)
{
Console.Error.WriteLine(
$"OpenAI request exception: {ex.GetType().FullName}: {ex.Message}. Endpoint={_endpoint}, Deployment={_deployment}");
return AppDefaults.SummaryFailedMessage;
}
}
private static string TruncateForLog(string? value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value ?? string.Empty;
}
return value[..maxLength] + "...(truncated)";
}
}
internal static class AppDefaults
{
public const string DefaultOwner = "tfsugjp";
public const string DefaultRepository = "devblogradio";
public const string SummaryFailedMessage = "要約に失敗しました。";
public const string ContentFailedMessage = "本文の取得に失敗しました。";
public const string PipelineFailedMessage = "要約処理中にエラーが発生しました。";
}
internal static class RepositoryPaths
{
public static string FindRepositoryRoot()
{
foreach (var startPath in GetStartPaths())
{
var current = new DirectoryInfo(startPath);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "feedsource.txt")))
{
return current.FullName;
}
current = current.Parent;
}
}
throw new InvalidOperationException("Unable to locate repository root containing feedsource.txt.");
}
private static IEnumerable<string> GetStartPaths()
{
yield return Directory.GetCurrentDirectory();
yield return AppContext.BaseDirectory;
}
}
internal sealed record GitHubRepository(string Owner, string Name)
{
public static GitHubRepository FromEnvironment(string defaultOwner, string defaultName)
{
var repositoryValue = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY");
if (string.IsNullOrWhiteSpace(repositoryValue))
{
return new GitHubRepository(defaultOwner, defaultName);
}
var parts = repositoryValue.Split('/', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length == 2
? new GitHubRepository(parts[0], parts[1])
: new GitHubRepository(defaultOwner, defaultName);
}
}
internal sealed record BlogContentResult(string Title, string Content, string Error);
internal sealed record ChatCompletionRequest(
[property: JsonPropertyName("max_completion_tokens")] int MaxCompletionTokens,
string Model,
ChatMessage[] Messages);
internal sealed record ChatMessage(string Role, string Content);
internal sealed record ChatCompletionResponse(ChatChoice[]? Choices);
internal sealed record ChatChoice(
ChatMessage? Message,
[property: JsonPropertyName("finish_reason")] string? FinishReason);
internal static class JsonOptions
{
public static readonly JsonSerializerOptions Default = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
}