Families.AutoWriter 1.0.0.0
Families 콘텐츠를 반복 작업 없이 작성·등록하도록 돕는 자동화 작성 도구입니다.
로딩중...
검색중...
일치하는것 없음
WriterSession.cs
이 파일의 문서화 페이지로 가기
1using System.Collections.ObjectModel;
2using System.IO;
3using System.Net.Http;
4using System.Text;
5using System.Text.Json;
6using System.Text.RegularExpressions;
7using Dreamine.MVVM.Attributes;
8using Dreamine.MVVM.ViewModels;
11using Microsoft.Win32;
12
14
23public enum SessionMode
24{
34
44}
45
54public sealed record IntervalOption(int Minutes, string Label)
55{
72 public override string ToString() => Label;
73}
74
83public sealed record WaitOption(int Seconds, string Label)
84{
101 public override string ToString() => Label;
102}
103
112public sealed partial class WriterSession : ViewModelBase
113{
122 private readonly PostWriterService _writer = new();
131 private readonly PromptHistoryService _history = new();
132
133 // ── 탭 모드 (Travel / Cooking) ─────────────────────────────
142 public SessionMode Mode { get; set; } = SessionMode.Travel;
143
144 // ── 브라우저 위임 (코드비하인드에서 주입) ───────────────────
153 public Func<string, Task<string?>>? ExecuteScriptAsync { get; set; }
162 public Action<string>? NavigateTab { get; set; }
163
164 // ── 루프 태스크 ────────────────────────────────────────────
173 private readonly System.Windows.Threading.Dispatcher _uiDispatcher;
182 private DateTime _nextLoopAt = DateTime.MaxValue;
191 private CancellationTokenSource? _loopCts;
200 private Task? _loopTask;
209 private int _cycleCount;
218 private readonly HashSet<string> _usedImageUrls = new(StringComparer.OrdinalIgnoreCase);
227 private int _albumIndex;
236 private const int ExtractionRetryDelaySeconds = 30;
245 private const int ExtractionRetryMaxAttempts = 10;
246
247 // ── 컬렉션 ─────────────────────────────────────────────────
256 public ObservableCollection<string> Slugs { get; } = [];
265 public ObservableCollection<AlbumInfo> Albums { get; } = [];
266
267 // ── 콤보박스 상수 옵션 ─────────────────────────────────────
276 public static IntervalOption[] IntervalOptions { get; } =
277 [
278 new(5, "5분"), new(10, "10분"), new(15, "15분"),
279 new(20, "20분"), new(30, "30분"), new(60, "1시간"),
280 new(120, "2시간"), new(180,"3시간"), new(360,"6시간"),
281 ];
290 public static WaitOption[] WaitOptions { get; } =
291 [
292 new(15, "15초"), new(20, "20초"), new(30, "30초"),
293 new(45, "45초"), new(60, "1분"), new(90, "1분 30초"),
294 new(120, "2분"), new(150,"2분 30초"), new(180,"3분"),
295 new(210, "3분 30초"),
296 ];
305 public static int[] NewChatOptions { get; } = [0, 3, 5, 10, 20, 30];
306
307 // ── [DreamineProperty] — 단순 속성 ──────────────────────────
316 [DreamineProperty] private bool _albumRotationEnabled;
325 [DreamineProperty] private int _newChatEveryN = 5;
334 [DreamineProperty] private string _loopStatus = "⏸ 루프 꺼짐";
343 [DreamineProperty] private string _loopCountdown = "";
352 [DreamineProperty] private MediaPosition _mediaPosition = MediaPosition.Bottom;
361 [DreamineProperty] private string _promptText = BuildTravelPrompt([]);
370 [DreamineProperty] private string _promptStatus = "";
379 [DreamineProperty] private string _extractStatus = "";
380
381 // ── 사이드이펙트 있는 속성 — 수동 구현 ──────────────────────
390 private string _appDataRoot = DetectDefaultRoot();
399 public string AppDataRoot
400 {
401 get => _appDataRoot;
402 set { if (SetProperty(ref _appDataRoot, value)) { _writer.AppDataRoot = value; RefreshSlugs(); } }
403 }
404
413 private string _selectedSlug = "";
422 public string SelectedSlug
423 {
424 get => _selectedSlug;
425 set { if (SetProperty(ref _selectedSlug, value)) RefreshAlbums(); }
426 }
427
446 {
447 get => _selectedAlbum;
448 set => SetProperty(ref _selectedAlbum, value);
449 }
450
459 private bool _loopEnabled;
468 public bool LoopEnabled
469 {
470 get => _loopEnabled;
471 set
472 {
473 if (SetProperty(ref _loopEnabled, value))
474 {
475 OnPropertyChanged(nameof(LoopLabel));
476 if (value) StartLoop(); else StopLoop();
477 }
478 }
479 }
480
488 public string LoopLabel => LoopEnabled ? "▶ 실행 중" : "⏸ 루프 꺼짐";
489
508 {
509 get => _selectedInterval;
510 set { if (SetProperty(ref _selectedInterval, value) && LoopEnabled) StartLoop(); }
511 }
512
521 private WaitOption _aiWaitOption = null!;
531 {
532 get => _aiWaitOption;
533 set => SetProperty(ref _aiWaitOption, value);
534 }
535
536 // ── 생성자 ─────────────────────────────────────────────────
553 public WriterSession(string? initialPrompt = null)
554 {
555 _uiDispatcher = System.Windows.Application.Current.Dispatcher;
557 _aiWaitOption = WaitOptions[2]; // 30초
558 if (initialPrompt != null) _promptText = initialPrompt;
559 RefreshSlugs();
560 }
561
562 // ── 커맨드 ─────────────────────────────────────────────────
571 [DreamineCommand] private void RefreshSlugList() { RefreshSlugs(); RefreshAlbums(); }
580 [DreamineCommand] private void RunNow() => _ = RunLoopCycleAsync(CancellationToken.None);
589 [DreamineCommand] private void SendPromptNow() => _ = SendPromptNowAsync();
598 [DreamineCommand] private void ExtractAndSave() => _ = ExtractAndSaveAsync();
607 [DreamineCommand] private void ClearPromptHistory() { _history.Clear(); PromptStatus = "🗑 초기화"; }
616 [DreamineCommand] private void BrowseAppData()
617 {
618 var dlg = new OpenFolderDialog { Title = "Families.Web App_Data 폴더 선택" };
619 if (dlg.ShowDialog() == true) AppDataRoot = dlg.FolderName;
620 }
621
622 // ── 슬러그 / 앨범 ─────────────────────────────────────────
631 private void RefreshSlugs()
632 {
633 _writer.AppDataRoot = AppDataRoot;
634 Slugs.Clear();
635 foreach (var s in _writer.GetSlugs()) Slugs.Add(s);
636 if (Slugs.Count > 0 && string.IsNullOrEmpty(SelectedSlug))
637 SelectedSlug = Slugs[0];
638 }
639
648 private void RefreshAlbums()
649 {
650 Albums.Clear();
651 if (string.IsNullOrWhiteSpace(SelectedSlug)) return;
652 foreach (var a in _writer.GetAlbums(SelectedSlug)) Albums.Add(a);
653 _albumIndex = 0;
654 SelectedAlbum = Albums.Count > 0 ? Albums[0] : null;
656 }
657
667 {
668 if (string.IsNullOrWhiteSpace(SelectedSlug)) return;
669 var existing = _writer.GetExistingTitles(SelectedSlug);
670 PromptText = Mode == SessionMode.Cooking
671 ? BuildCookingPrompt(existing, _writer.GetCookingTimelineState(SelectedSlug))
672 : BuildTravelPrompt(existing);
673 }
674
675 // ── 루프 제어 ─────────────────────────────────────────────
684 private int IntervalMinutes => SelectedInterval?.Minutes ?? 10;
685
694 private void StartLoop()
695 {
696 _loopCts?.Cancel();
697 _loopCts?.Dispose();
698 _loopCts = new CancellationTokenSource();
699 _nextLoopAt = DateTime.Now.AddMinutes(IntervalMinutes);
700 var ct = _loopCts.Token;
701 _loopTask = Task.Run(() => LoopWorkerAsync(ct));
702 var nc = NewChatEveryN > 0 ? $" | 🔄{NewChatEveryN}개" : "";
703 LoopStatus = $"✅ {SelectedInterval?.Label} | AI대기 {AiWaitOption?.Label}{nc}";
704 }
705
714 private void StopLoop()
715 {
716 _loopCts?.Cancel();
717 _loopCts?.Dispose();
718 _loopCts = null;
719 _nextLoopAt = DateTime.MaxValue;
720 LoopCountdown = "";
721 LoopStatus = "⏸ 루프 꺼짐";
722 }
723
732 public void StopAutomation()
733 {
734 if (LoopEnabled)
735 LoopEnabled = false;
736 else
737 StopLoop();
738 }
739
740 // 백그라운드 루프 워커 — 각 탭이 독립적으로 돌아감
765 private async Task LoopWorkerAsync(CancellationToken ct)
766 {
767 try
768 {
769 while (!ct.IsCancellationRequested)
770 {
771 // 카운트다운
772 try
773 {
774 while (!ct.IsCancellationRequested && DateTime.Now < _nextLoopAt)
775 {
776 var r = _nextLoopAt - DateTime.Now;
777 if (r > TimeSpan.Zero)
778 {
779 var text = r.TotalHours >= 1
780 ? $"다음 {(int)r.TotalHours}:{r.Minutes:D2}:{r.Seconds:D2}"
781 : $"다음 {r.Minutes:D2}:{r.Seconds:D2}";
782 await _uiDispatcher.InvokeAsync(() => LoopCountdown = text);
783 }
784 await Task.Delay(1000, ct).ConfigureAwait(false);
785 }
786 }
787 catch (OperationCanceledException) { break; }
788
789 if (ct.IsCancellationRequested) break;
790
791 try
792 {
793 await RunLoopCycleAsync(ct).ConfigureAwait(false);
794 }
795 catch (OperationCanceledException) { break; }
796 catch (Exception ex)
797 {
798 await _uiDispatcher.InvokeAsync(() => LoopStatus = $"❌ 루프 오류: {ex.Message}");
799 await Task.Delay(3000, ct).ConfigureAwait(false);
800 }
801
802 if (!ct.IsCancellationRequested)
803 _nextLoopAt = DateTime.Now.AddMinutes(IntervalMinutes);
804 }
805 }
806 catch (OperationCanceledException) { }
807 catch (Exception ex)
808 {
809 await _uiDispatcher.InvokeAsync(() => LoopStatus = $"❌ 루프 중단: {ex.Message}");
810 }
811 finally
812 {
813 await _uiDispatcher.InvokeAsync(() => LoopCountdown = "");
814 }
815 }
816
817 // ── 루프 사이클 ───────────────────────────────────────────
842 private async Task RunLoopCycleAsync(CancellationToken ct)
843 {
844 // 상태 읽기는 UI 스레드에서
845 var (slug, album, prompt, execFn) = await _uiDispatcher.InvokeAsync(() =>
847
848 if (string.IsNullOrWhiteSpace(slug)) { await SetStatus("❌ 슬러그를 선택하세요."); return; }
849 if (string.IsNullOrWhiteSpace(prompt)) { await SetStatus("❌ 프롬프트를 입력하세요."); return; }
850 if (execFn == null) { await SetStatus("❌ 브라우저 준비 안 됨"); return; }
851
852 var albumName = album?.Name ?? "전체 타임라인";
853 var filled = prompt.Replace("{앨범}", albumName).Replace("{album}", albumName);
854
855
856 try
857 {
858 // 전송 전 현재 마지막 응답을 베이스라인으로 캡처 — 이전 응답 오탐 방지
859 var baselineRaw = await await _uiDispatcher.InvokeAsync(() => execFn(ResponseExtractor.BuildExtractScript()));
860 string baselineText = "";
861 try
862 {
863 using var bd = System.Text.Json.JsonDocument.Parse(baselineRaw ?? "{}");
864 baselineText = bd.RootElement.TryGetProperty("text", out var bt) ? bt.GetString() ?? "" : "";
865 }
866 catch { }
867
868 await SetStatus($"📤 [{albumName}] 전송 중...");
869 await await _uiDispatcher.InvokeAsync(() => execFn(PromptInjector.BuildInjectScript(filled)));
870
871 // AI 시작 대기 (10초)
872 await SetStatus($"⏳ [{albumName}] AI 시작 대기...");
873 await Task.Delay(10_000, ct).ConfigureAwait(false);
874
875 // 완료 판정:
876 // 1순위 — ===VIDEO=== 발견 시 즉시 완료 (completed 플래그)
877 // 2순위 — 종료 마커가 있는 텍스트 안정화: 15초 간격으로 2회 연속 동일하면 완료
878 string? raw = null;
879 string prevText = "";
880 int stableCount = 0;
881 for (int retry = 0; retry <= 60; retry++) // 최대 15분
882 {
883 if (ct.IsCancellationRequested) return;
884
885 var extracted = await await _uiDispatcher.InvokeAsync(() => execFn(ResponseExtractor.BuildExtractScript()));
886 string curText = "";
887 bool completed = false;
888 try
889 {
890 using var doc = System.Text.Json.JsonDocument.Parse(extracted ?? "{}");
891 curText = doc.RootElement.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
892 completed = doc.RootElement.TryGetProperty("completed", out var c) && c.GetBoolean();
893 }
894 catch { curText = extracted ?? ""; }
895
896 // 이전 응답(베이스라인)과 동일하면 아직 새 응답이 아님 → 스킵
897 bool isNewResponse = curText.Length > 20 && curText != baselineText;
898
899 // 1순위: 종료 마커 확인 (새 응답일 때만)
900 if (completed && isNewResponse)
901 {
902 raw = extracted;
903 break;
904 }
905
906 // 2순위: 텍스트 안정화 (새 응답일 때만)
907 var hasEndMarker = curText.Contains("===VIDEO===", StringComparison.OrdinalIgnoreCase);
908 if (isNewResponse && hasEndMarker && curText == prevText)
909 {
910 stableCount++;
911 if (stableCount >= 2)
912 {
913 raw = extracted;
914 break;
915 }
916 }
917 else
918 {
919 stableCount = 0;
920 }
921 prevText = curText;
922
923 var elapsed = 8 + retry * 15;
924 await SetStatus($"⏳ [{albumName}] AI 생성 중... ({elapsed}s)");
925 await Task.Delay(15_000, ct).ConfigureAwait(false);
926 }
927
928 if (raw == null)
929 {
930 await SetStatus("⏱ AI 응답 대기 시간 초과 (응답 없음)");
931 return;
932 }
933
934 var mode = await _uiDispatcher.InvokeAsync(() => Mode);
935 var mediaPos = await _uiDispatcher.InvokeAsync(() => MediaPosition);
936 var albumId = album?.Id ?? "";
937 var newChatN = await _uiDispatcher.InvokeAsync(() => NewChatEveryN);
938 var rotEnabled = await _uiDispatcher.InvokeAsync(() => AlbumRotationEnabled);
939
940 for (var extractAttempt = 1; extractAttempt <= ExtractionRetryMaxAttempts; extractAttempt++)
941 {
942 if (mode == SessionMode.Cooking)
943 {
944 var posts = await ParseCookingResponseAsync(raw);
945 if (posts.Count == 0)
946 {
947 if (extractAttempt >= ExtractionRetryMaxAttempts)
948 {
949 await SetStatus("❌ 요리 포맷 추출 실패 (===DETAIL_NNN=== 섹션 없음)");
950 return;
951 }
952
953 await SetStatus($"⏳ [{albumName}] AI 응답 불완전 - {ExtractionRetryDelaySeconds}초 후 재추출 ({extractAttempt}/{ExtractionRetryMaxAttempts})");
954 await Task.Delay(TimeSpan.FromSeconds(ExtractionRetryDelaySeconds), ct).ConfigureAwait(false);
955 raw = await await _uiDispatcher.InvokeAsync(() => execFn(ResponseExtractor.BuildExtractScript()));
956 continue;
957 }
958
959 // 중복 제목 방지
960 var existingCookingTitles = _writer.GetExistingTitles(slug);
961 var existingCookingNumbers = existingCookingTitles
963 .Where(n => n > 0)
964 .ToHashSet();
965 var newPosts = posts.Where(p =>
966 !existingCookingTitles.Any(t => string.Equals(t, p.Title, StringComparison.OrdinalIgnoreCase)) &&
967 !existingCookingNumbers.Contains(ExtractCookingNumberFromTitle(p.Title))
968 ).ToList();
969 if (newPosts.Count == 0) { await SetStatus($"⏭ [{albumName}] 모두 중복 제목 스킵"); return; }
970
971 foreach (var p in newPosts) await _writer.SavePostAsync(slug, p, []);
972 _cycleCount++;
973 await SetStatus($"✅ [{albumName}] #{_cycleCount} {newPosts.Count}개 저장! ({DateTime.Now:HH:mm})");
974 break;
975 }
976 else
977 {
978 var result = ParseTravelResponse(raw);
979 if (result is null || string.IsNullOrWhiteSpace(result.Content))
980 {
981 if (extractAttempt >= ExtractionRetryMaxAttempts)
982 {
983 await SetStatus("❌ 추출 실패 (===CONTENT=== 섹션 없음 — AI 응답 불완전)");
984 return;
985 }
986
987 await SetStatus($"⏳ [{albumName}] AI 응답 불완전 - {ExtractionRetryDelaySeconds}초 후 재추출 ({extractAttempt}/{ExtractionRetryMaxAttempts})");
988 await Task.Delay(TimeSpan.FromSeconds(ExtractionRetryDelaySeconds), ct).ConfigureAwait(false);
989 raw = await await _uiDispatcher.InvokeAsync(() => execFn(ResponseExtractor.BuildExtractScript()));
990 continue;
991 }
992
993 List<string> validPhotos = [];
994 if (result.Photos.Count > 0)
995 {
996 await SetStatus("🔍 이미지 검증 중...");
997 var allValid = await ValidateImageUrlsAsync(result.Photos, msg => _ = SetStatus(msg));
998 validPhotos = allValid.Take(3).ToList(); // 여행: 최대 3개 사용
999 }
1000 var content = NormalizeTravelContent(RemoveInvalidInlineImages(result.Content, validPhotos));
1001
1002 var postTitle = !string.IsNullOrWhiteSpace(result.Title) ? result.Title : $"[{albumName}] {DateTime.Now:MM/dd HH:mm}";
1003
1004 // 중복 제목 방지 — 같은 제목이 이미 저장됐으면 스킵
1005 var existingTitles = _writer.GetExistingTitles(slug);
1006 if (existingTitles.Any(t => string.Equals(t, postTitle, StringComparison.OrdinalIgnoreCase)))
1007 {
1008 await SetStatus($"⏭ [{albumName}] 중복 제목 스킵: {postTitle[..Math.Min(30, postTitle.Length)]}...");
1009 return;
1010 }
1011
1012 var post = new PostEntry
1013 {
1014 Title = postTitle,
1015 Content = content,
1016 AlbumId = albumId,
1017 PostedAt = DateTime.Now,
1018 MediaPosition = mediaPos,
1019 PhotoFileNames = validPhotos,
1020 VideoFileNames = result.Videos,
1021 };
1022 await _writer.SavePostAsync(slug, post, []);
1023 _cycleCount++;
1024 await SetStatus($"✅ [{albumName}] #{_cycleCount} 저장! ({DateTime.Now:HH:mm}) 📷{validPhotos.Count} 🎬{result.Videos.Count}");
1025 break;
1026 }
1027 }
1028
1029 // 저장 완료 후 프롬프트 갱신 — 방금 쓴 제목이 다음 사이클 "제외 목록"에 반영됨
1030 await _uiDispatcher.InvokeAsync(() => RefreshPromptWithExisting());
1031
1032 if (rotEnabled) await _uiDispatcher.InvokeAsync(() => RotateAlbum());
1033
1034 if (newChatN > 0 && _cycleCount % newChatN == 0)
1035 {
1036 await SetStatus("🔄 새 채팅으로 이동 중...");
1037 await Task.Delay(2000, ct).ConfigureAwait(false);
1038 await _uiDispatcher.InvokeAsync(() => NavigateTab?.Invoke("__new_chat__"));
1039 // 새 채팅 페이지 로딩 완료까지 충분히 대기
1040 for (int i = 15; i > 0; i--)
1041 {
1042 if (ct.IsCancellationRequested) return;
1043 await SetStatus($"🔄 새 채팅 로딩 대기 {i}초...");
1044 await Task.Delay(1000, ct).ConfigureAwait(false);
1045 }
1046 await SetStatus("✅ 새 채팅 준비 완료");
1047 }
1048 }
1049 catch (OperationCanceledException) { await SetStatus("⏹ 취소됨"); }
1050 catch (Exception ex) { await SetStatus($"❌ {ex.Message}"); }
1051 }
1052
1077 private Task SetStatus(string msg) => _uiDispatcher.InvokeAsync(() => LoopStatus = msg).Task;
1078
1087 private void RotateAlbum()
1088 {
1089 if (Albums.Count == 0) return;
1090 _albumIndex = (_albumIndex + 1) % Albums.Count;
1092 }
1093
1094 // ── 수동 전송 / 추출 ──────────────────────────────────────
1111 private async Task SendPromptNowAsync()
1112 {
1113 if (string.IsNullOrWhiteSpace(PromptText)) { PromptStatus = "❌ 프롬프트를 입력하세요."; return; }
1114 if (ExecuteScriptAsync == null) { PromptStatus = "❌ 브라우저 준비 안 됨"; return; }
1115 var albumName = SelectedAlbum?.Name ?? "전체";
1116 var prompt = PromptText.Replace("{앨범}", albumName).Replace("{album}", albumName);
1118 PromptStatus = $"✅ 전송 ({DateTime.Now:HH:mm})";
1119 }
1120
1137 private async Task ExtractAndSaveAsync()
1138 {
1139 if (string.IsNullOrWhiteSpace(SelectedSlug)) { ExtractStatus = "❌ 슬러그를 선택하세요."; return; }
1140 if (ExecuteScriptAsync == null) { ExtractStatus = "❌ 브라우저 준비 안 됨"; return; }
1141
1142 ExtractStatus = "⏳ AI 응답 추출 중...";
1144
1145 try
1146 {
1147 for (var extractAttempt = 1; extractAttempt <= ExtractionRetryMaxAttempts; extractAttempt++)
1148 {
1149 if (Mode == SessionMode.Cooking)
1150 {
1151 var posts = await ParseCookingResponseAsync(raw);
1152 if (posts.Count == 0)
1153 {
1154 if (extractAttempt >= ExtractionRetryMaxAttempts)
1155 {
1156 ExtractStatus = "❌ 요리 포맷 추출 실패 (===DETAIL_NNN_CONTENT=== 없음)";
1157 return;
1158 }
1159
1160 ExtractStatus = $"⏳ AI 응답 불완전 - {ExtractionRetryDelaySeconds}초 후 재추출 ({extractAttempt}/{ExtractionRetryMaxAttempts})";
1161 await Task.Delay(TimeSpan.FromSeconds(ExtractionRetryDelaySeconds));
1163 continue;
1164 }
1165
1166 var existingCookingTitles = _writer.GetExistingTitles(SelectedSlug);
1167 var existingCookingNumbers = existingCookingTitles
1169 .Where(n => n > 0)
1170 .ToHashSet();
1171 var newPosts = posts.Where(p =>
1172 !existingCookingTitles.Any(t => string.Equals(t, p.Title, StringComparison.OrdinalIgnoreCase)) &&
1173 !existingCookingNumbers.Contains(ExtractCookingNumberFromTitle(p.Title))
1174 ).ToList();
1175 if (newPosts.Count == 0) { ExtractStatus = "⏭ 모두 중복 제목/번호 스킵"; return; }
1176
1177 foreach (var p in newPosts) await _writer.SavePostAsync(SelectedSlug, p, []);
1178 ExtractStatus = $"✅ {newPosts.Count}개 저장! ({DateTime.Now:HH:mm})";
1179 break;
1180 }
1181 else
1182 {
1183 var result = ParseTravelResponse(raw);
1184 if (result is null || string.IsNullOrWhiteSpace(result.Content))
1185 {
1186 if (extractAttempt >= ExtractionRetryMaxAttempts)
1187 {
1188 ExtractStatus = "❌ 응답 없음";
1189 return;
1190 }
1191
1192 ExtractStatus = $"⏳ AI 응답 불완전 - {ExtractionRetryDelaySeconds}초 후 재추출 ({extractAttempt}/{ExtractionRetryMaxAttempts})";
1193 await Task.Delay(TimeSpan.FromSeconds(ExtractionRetryDelaySeconds));
1195 continue;
1196 }
1197
1198 var albumName = SelectedAlbum?.Name ?? "전체";
1199 List<string> validPhotos = [];
1200 if (result.Photos.Count > 0)
1201 {
1202 ExtractStatus = "🔍 이미지 검증...";
1203 var allValid = await ValidateImageUrlsAsync(result.Photos, msg => ExtractStatus = msg);
1204 validPhotos = allValid.Take(3).ToList(); // 여행: 최대 3개
1205 }
1206 var content = NormalizeTravelContent(RemoveInvalidInlineImages(result.Content, validPhotos));
1207
1208 var post = new PostEntry
1209 {
1210 Title = !string.IsNullOrWhiteSpace(result.Title) ? result.Title : $"[{albumName}] {DateTime.Now:MM/dd HH:mm}",
1211 Content = content,
1212 AlbumId = SelectedAlbum?.Id ?? "",
1214 PostedAt = DateTime.Now,
1215 PhotoFileNames = validPhotos,
1216 VideoFileNames = result.Videos,
1217 };
1218 await _writer.SavePostAsync(SelectedSlug, post, []);
1219 ExtractStatus = $"✅ [{albumName}] 저장! ({DateTime.Now:HH:mm}) 📷{validPhotos.Count} 🎬{result.Videos.Count}";
1220 break;
1221 }
1222 }
1223 }
1224 catch (Exception ex) { ExtractStatus = $"❌ {ex.Message}"; }
1225 }
1226
1227 // ── 파싱 / 검증 ───────────────────────────────────────────
1236 private sealed record AiPostResult(string Title, string Content, List<string> Photos, List<string> Videos);
1237
1238 // AI 응답 raw JSON → 텍스트 변환
1263 private static string UnwrapRaw(string? raw)
1264 {
1265 if (string.IsNullOrWhiteSpace(raw)) return "";
1266 try
1267 {
1268 var u = JsonSerializer.Deserialize<string>(raw) ?? raw;
1269 var d = JsonDocument.Parse(u);
1270 if (d.RootElement.TryGetProperty("text", out var tp)) return tp.GetString() ?? u;
1271 return u;
1272 }
1273 catch { return raw.Trim('"').Replace("\\n", "\n").Replace("\\\"", "\""); }
1274 }
1275
1276 // ===TAG=== 섹션 추출 공통 헬퍼
1309 private static string GetSection(string text, string tag)
1310 {
1311 var marker = $"==={tag}===";
1312 var s = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
1313 if (s < 0) return "";
1314 s += marker.Length;
1315 var n = text.IndexOf("===", s);
1316 return text[s..(n > s ? n : text.Length)].Trim();
1317 }
1318
1319 // content 안의 [IMAGES N번째 URL] 플레이스홀더를 실제 URL로 치환
1320 // offset: 이 content가 몇 번째 이미지부터 시작하는지 (여행=0, 요리 각 포스트=해당 순번)
1361 private static string SubstituteImages(string content, IList<string> urls, int offset = 0)
1362 {
1363 // 한국어 순서 표현 매핑
1364 string[] ordinals = ["첫 번째", "두 번째", "세 번째", "네 번째", "다섯 번째"];
1365 var result = content;
1366
1367 for (int i = 0; i < urls.Count; i++)
1368 {
1369 // "[IMAGES N번째 URL]" 형태 치환
1370 if (i < ordinals.Length)
1371 result = result.Replace($"[IMAGES {ordinals[i]} URL]", urls[i], StringComparison.OrdinalIgnoreCase);
1372 }
1373
1374 // offset 기반 치환: 요리 포스트별로 "첫 번째"가 실제로는 offset번째 이미지를 가리킴
1375 if (offset > 0 && offset < ordinals.Length && offset < urls.Count + offset)
1376 {
1377 // 이미 치환됐을 수 있으니 남은 [IMAGES 첫 번째 URL] 형태만 처리
1378 result = result.Replace("[IMAGES 첫 번째 URL]", urls.Count > 0 ? urls[0] : "", StringComparison.OrdinalIgnoreCase);
1379 }
1380
1381 // 치환 안 된 플레이스홀더 img 태그 제거 (깨진 이미지 방지)
1382 result = System.Text.RegularExpressions.Regex.Replace(
1383 result,
1384 @"<img\s[^>]*src=""[^""]*\‍[IMAGES[^\‍]]*\‍][^""]*""[^>]*/?>",
1385 "",
1386 System.Text.RegularExpressions.RegexOptions.IgnoreCase);
1387
1388 return result;
1389 }
1390
1391 // ── 여행 모드 파싱 (===TITLE=== / ===CONTENT=== / ===IMAGES=== / ===VIDEO===) ──
1416 private static AiPostResult? ParseTravelResponse(string? raw)
1417 {
1418 var text = UnwrapRaw(raw);
1419 if (string.IsNullOrWhiteSpace(text)) return null;
1420
1421 var content = GetSection(text, "CONTENT");
1422 if (string.IsNullOrWhiteSpace(content))
1423 {
1424 // ===CONTENT=== 마커를 AI가 빠뜨린 경우 — ===TITLE=== 다음 줄 ~ ===IMAGES=== 앞 텍스트로 fallback
1425 var titleEnd = text.IndexOf("===TITLE===", StringComparison.OrdinalIgnoreCase);
1426 var imagesIdx = text.IndexOf("===IMAGES===", StringComparison.OrdinalIgnoreCase);
1427 if (titleEnd >= 0 && imagesIdx > titleEnd)
1428 {
1429 var afterTitle = text.IndexOf('\n', titleEnd + "===TITLE===".Length);
1430 if (afterTitle >= 0)
1431 {
1432 var afterTitleLine = text.IndexOf('\n', afterTitle + 1);
1433 if (afterTitleLine >= 0 && afterTitleLine < imagesIdx)
1434 content = text[afterTitleLine..imagesIdx].Trim();
1435 }
1436 }
1437 if (string.IsNullOrWhiteSpace(content)) return null;
1438 }
1439
1440 var photos = GetSection(text, "IMAGES")
1441 .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
1442 .Where(l => l.StartsWith("http", StringComparison.OrdinalIgnoreCase))
1443 .Where(IsPlausibleImageUrl)
1444 .ToList();
1445 var videos = GetSection(text, "VIDEO")
1446 .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
1447 .Where(IsAllowedVideoUrl).ToList();
1448
1449 content = NormalizeTravelContent(SubstituteImages(content, photos));
1450 photos = MergeUrls(photos, ExtractImageUrls(content));
1451 return new AiPostResult(GetSection(text, "TITLE"), content, photos, videos);
1452 }
1453
1454 // ── 요리 모드 파싱 (===DETAIL_NNN_TITLE=== / ===DETAIL_NNN_CONTENT===) ──
1479 private async Task<List<PostEntry>> ParseCookingResponseAsync(string? raw)
1480 {
1481 var text = UnwrapRaw(raw);
1482 if (string.IsNullOrWhiteSpace(text)) return [];
1483
1484 // IMAGES 섹션에서 이미지 URL 목록 — 실제 존재하는 것만 사용
1485 var rawImageUrls = GetSection(text, "IMAGES")
1486 .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
1487 .Where(l => l.StartsWith("http", StringComparison.OrdinalIgnoreCase))
1488 .Where(IsPlausibleImageUrl)
1489 .ToList();
1490 // 이미 사용한 이미지 URL 제외하여 중복 이미지 방지
1491 var validImages = rawImageUrls.Count > 0
1492 ? await ValidateImageUrlsAsync(rawImageUrls, null, _usedImageUrls)
1493 : [];
1494 // 요리: 포스트당 최대 2개 사용 — 검증 통과한 전체 풀에서 순서대로 배분
1495 const int CookingImagesPerPost = 2;
1496 var imageUrls = validImages.ToArray();
1497
1498 var posts = new List<PostEntry>();
1499 var timeline = _writer.GetCookingTimelineState(SelectedSlug);
1500 var nextPostedAt = GetNextCookingSlot(timeline.LastPostedAt);
1501 var nextNumber = timeline.LastNumber > 0 ? timeline.LastNumber + 1 : 1;
1502
1503 // ===DETAIL_NNN_TITLE=== 패턴으로 포스트 수 감지
1504 // AI가 [번호1], [새숫자] 같은 자리표시자를 남겨도 가능한 범위에서 복구한다.
1505 var detailNums = Regex
1506 .Matches(text, @"===DETAIL_(?<num>\d+|\‍[[^\‍]]+\‍])_TITLE===", RegexOptions.IgnoreCase)
1507 .Select(m => m.Groups["num"].Value)
1508 .Distinct()
1509 .OrderBy(n => int.TryParse(n, out var i) ? i : int.MaxValue)
1510 .ThenBy(n => n)
1511 .ToList();
1512
1513 int imgIdx = 0;
1514 foreach (var num in detailNums)
1515 {
1516 var title = GetSection(text, $"DETAIL_{num}_TITLE");
1517 var date = GetSection(text, $"DETAIL_{num}_DATE");
1518 var content = GetSection(text, $"DETAIL_{num}_CONTENT");
1519
1520 if (string.IsNullOrWhiteSpace(content)) continue;
1521 var displayNum = int.TryParse(num, out _) ? num : $"{DateTime.Now:HHmm}{posts.Count + 1}";
1522 var postedAt = nextPostedAt;
1523 var mealName = GetCookingMealName(postedAt);
1524 title = NormalizeCookingTitle(title, nextNumber, mealName);
1525
1526 // 이 포스트에 해당하는 이미지 — 검증 풀에서 최대 CookingImagesPerPost개 순서대로
1527 var postImages = imageUrls
1528 .Skip(imgIdx)
1529 .Take(CookingImagesPerPost)
1530 .ToList();
1531 imgIdx += CookingImagesPerPost;
1532
1533 var substitutedSource = SubstituteImages(content, postImages);
1534 var bodyImages = ExtractImageUrls(substitutedSource);
1535 var substituted = NormalizeCookingContent(substitutedSource);
1536
1537 // 사용된 이미지 URL 추적 (다음 사이클에서 중복 방지)
1538 foreach (var img in postImages) _usedImageUrls.Add(img);
1539
1540 posts.Add(new PostEntry
1541 {
1542 Title = string.IsNullOrWhiteSpace(title) ? $"[1983 집밥육아 #{nextNumber}] {mealName} 집밥 기록" : title,
1543 Content = substituted,
1544 AlbumId = SelectedAlbum?.Id ?? "",
1545 PostedAt = postedAt,
1547 PhotoFileNames = MergeUrls(postImages, bodyImages),
1548 VideoFileNames = [],
1549 });
1550
1551 nextPostedAt = GetNextCookingSlot(postedAt);
1552 nextNumber++;
1553 }
1554
1555 return posts;
1556 }
1557
1582 private static DateTime GetNextCookingSlot(DateTime? lastPostedAt)
1583 {
1584 if (lastPostedAt is null)
1585 return new DateTime(1983, 1, 1, 8, 0, 0);
1586
1587 var last = lastPostedAt.Value;
1588 return last.Hour switch
1589 {
1590 < 12 => last.Date.AddHours(12),
1591 < 18 => last.Date.AddHours(18),
1592 _ => last.Date.AddDays(1).AddHours(8),
1593 };
1594 }
1595
1620 private static string GetCookingMealName(DateTime postedAt) =>
1621 postedAt.Hour switch
1622 {
1623 < 12 => "아침",
1624 < 18 => "점심",
1625 _ => "저녁",
1626 };
1627
1668 private static string NormalizeCookingTitle(string title, int number, string mealName)
1669 {
1670 var normalized = string.IsNullOrWhiteSpace(title)
1671 ? $"[1983 집밥육아 #{number}] {mealName} 집밥 기록"
1672 : title.Trim();
1673
1674 normalized = Regex.Replace(normalized, @"#\d+|#번호\d+|#새숫자", $"#{number}", RegexOptions.IgnoreCase);
1675 normalized = normalized
1676 .Replace("[번호1]", number.ToString(), StringComparison.OrdinalIgnoreCase)
1677 .Replace("[번호2]", number.ToString(), StringComparison.OrdinalIgnoreCase)
1678 .Replace("[번호3]", number.ToString(), StringComparison.OrdinalIgnoreCase)
1679 .Replace("[새숫자]", number.ToString(), StringComparison.OrdinalIgnoreCase);
1680
1681 if (!Regex.IsMatch(normalized, @"#\d+"))
1682 normalized = $"[1983 집밥육아 #{number}] {normalized}";
1683
1684 normalized = Regex.IsMatch(normalized, @"아침|점심|저녁")
1685 ? Regex.Replace(normalized, @"아침|점심|저녁", mealName, RegexOptions.None, TimeSpan.FromMilliseconds(100))
1686 : Regex.Replace(normalized, @"(\‍]\s*)", "$1" + mealName + " ", RegexOptions.None, TimeSpan.FromMilliseconds(100));
1687
1688 return normalized;
1689 }
1690
1715 private static int ExtractCookingNumberFromTitle(string title)
1716 {
1717 var match = Regex.Match(title ?? "", @"#(?<n>\d+)");
1718 return match.Success && int.TryParse(match.Groups["n"].Value, out var number) ? number : 0;
1719 }
1720
1745 private static List<string> ExtractImageUrls(string content) =>
1746 Regex.Matches(content, @"<img\b[^>]*\bsrc\s*=\s*[""'](?<url>https?://[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase)
1747 .Select(m => System.Net.WebUtility.HtmlDecode(m.Groups["url"].Value.Trim()))
1748 .Where(u => u.StartsWith("http", StringComparison.OrdinalIgnoreCase))
1749 .ToList();
1750
1783 private static string RemoveInvalidInlineImages(string content, IReadOnlyCollection<string> allowedUrls)
1784 {
1785 if (string.IsNullOrWhiteSpace(content))
1786 return content;
1787
1788 var allowed = allowedUrls
1789 .Select(u => System.Net.WebUtility.HtmlDecode(u).Trim())
1790 .Where(u => u.Length > 0)
1791 .ToHashSet(StringComparer.OrdinalIgnoreCase);
1792
1793 return Regex.Replace(
1794 content,
1795 @"<img\b[^>]*>",
1796 match =>
1797 {
1798 var src = Regex.Match(match.Value, @"\bsrc\s*=\s*[""'](?<url>https?://[^""']+)[""']", RegexOptions.IgnoreCase);
1799 if (!src.Success)
1800 return "";
1801
1802 var url = System.Net.WebUtility.HtmlDecode(src.Groups["url"].Value.Trim());
1803 return allowed.Contains(url) ? match.Value : ""; // 검증 통과 URL만 유지
1804 },
1805 RegexOptions.IgnoreCase);
1806 }
1807
1840 private static List<string> MergeUrls(IEnumerable<string> primary, IEnumerable<string> secondary)
1841 {
1842 var merged = new List<string>();
1843 foreach (var url in primary.Concat(secondary))
1844 {
1845 var clean = url.Trim();
1846 if (clean.Length == 0) continue;
1847 if (!merged.Contains(clean, StringComparer.OrdinalIgnoreCase))
1848 merged.Add(clean);
1849 }
1850 return merged;
1851 }
1852
1877 private static string NormalizeTravelContent(string content)
1878 {
1879 var text = NormalizeKnownHeadings(content, new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
1880 {
1881 ["한눈에 보는 포인트"] = "## 한눈에 보는 포인트",
1882 ["이런 가족에게 추천"] = "## 이런 가족에게 추천",
1883 ["아이와 좋았던 점"] = "## 아이와 좋았던 점",
1884 ["동선과 시간표"] = "## 동선과 시간표",
1885 ["여행 경비 예상"] = "## 여행 경비 예상",
1886 ["주차·화장실·유모차 포인트"] = "## 주차·화장실·유모차 포인트",
1887 ["주차・화장실・유모차 포인트"] = "## 주차·화장실·유모차 포인트",
1888 ["준비물과 주의할 것"] = "## 준비물과 주의할 것",
1889 ["방문 전 확인할 것"] = "## 방문 전 확인할 것",
1890 ["총평"] = "## 총평",
1891 });
1892
1894 }
1895
1920 private static bool IsAllowedVideoUrl(string url)
1921 {
1922 if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return false;
1923 if (!url.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) &&
1924 !url.Contains("youtu.be/", StringComparison.OrdinalIgnoreCase))
1925 return true;
1926
1927 // 일반 watch/shorts 링크는 소유자 설정으로 앱 내 iframe 재생이 자주 막힌다.
1928 return url.Contains("/embed/", StringComparison.OrdinalIgnoreCase);
1929 }
1930
1955 private static string NormalizeCookingContent(string content)
1956 {
1957 var text = Regex.Replace(
1958 content,
1959 @"<img\b[^>]*>",
1960 "",
1961 RegexOptions.IgnoreCase);
1962
1963 text = NormalizeKnownHeadings(text, new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
1964 {
1965 ["오늘의 아기 밥"] = "## 오늘의 아기 밥",
1966 ["오늘의 가족 집밥"] = "## 오늘의 가족 집밥",
1967 ["만드는 법"] = "## 만드는 법",
1968 ["재료: 어른 2~3인분"] = "### 재료: 어른 2~3인분",
1969 ["재료: 어른 2-3인분"] = "### 재료: 어른 2~3인분",
1970 ["손질하기"] = "### 손질하기",
1971 ["양념에 재우기"] = "### 양념에 재우기",
1972 ["볶기"] = "### 볶기",
1973 ["끓이기"] = "### 끓이기",
1974 ["조리기"] = "### 조리기",
1975 ["부치기"] = "### 부치기",
1976 ["초보자 메모"] = "### 초보자 메모",
1977 ["별점"] = "## 별점",
1978 ["파워블로그 한줄평"] = "## 파워블로그 한줄평",
1979 });
1980
1982 }
1983
2016 private static string NormalizeKnownHeadings(string content, IReadOnlyDictionary<string, string> headings)
2017 {
2018 var lines = content.Replace("\r\n", "\n").Split('\n');
2019 for (var i = 0; i < lines.Length; i++)
2020 {
2021 var trimmed = lines[i].Trim();
2022 if (trimmed.StartsWith("#", StringComparison.Ordinal) ||
2023 trimmed.StartsWith("<", StringComparison.Ordinal) ||
2024 trimmed.Length == 0)
2025 continue;
2026
2027 if (headings.TryGetValue(trimmed, out var heading))
2028 lines[i] = heading;
2029 }
2030
2031 return string.Join("\n", lines);
2032 }
2033
2058 private static string NormalizeSimpleTables(string content)
2059 {
2060 var lines = content.Replace("\r\n", "\n").Split('\n').ToList();
2061 for (var i = 0; i < lines.Count; i++)
2062 {
2063 var expanded = TryExpandCollapsedPipeTable(lines[i]);
2064 if (expanded.Count > 0)
2065 {
2066 if (i > 0 && !string.IsNullOrWhiteSpace(lines[i - 1]) && expanded[0].Length > 0)
2067 expanded.Insert(0, "");
2068 if (i + 1 < lines.Count && !string.IsNullOrWhiteSpace(lines[i + 1]) && expanded[^1].Length > 0)
2069 expanded.Add("");
2070 lines.RemoveAt(i);
2071 lines.InsertRange(i, expanded);
2072 i += expanded.Count - 1;
2073 continue;
2074 }
2075
2076 var cols = SplitTableLine(lines[i]);
2077 if (cols.Count < 3 || !cols.Any(c => c.Contains("비용", StringComparison.OrdinalIgnoreCase))) continue;
2078
2079 var rows = new List<List<string>> { cols };
2080 var j = i + 1;
2081 while (j < lines.Count)
2082 {
2083 var row = SplitTableLine(lines[j]);
2084 if (row.Count != cols.Count) break;
2085 rows.Add(row);
2086 j++;
2087 }
2088
2089 if (rows.Count < 2) continue;
2090
2091 var md = new List<string>
2092 {
2093 "| " + string.Join(" | ", rows[0]) + " |",
2094 "| " + string.Join(" | ", rows[0].Select((_, idx) => idx == 1 ? "---:" : "---")) + " |"
2095 };
2096 md.AddRange(rows.Skip(1).Select(row => "| " + string.Join(" | ", row) + " |"));
2097 lines.RemoveRange(i, rows.Count);
2098 lines.InsertRange(i, md);
2099 i += md.Count - 1;
2100 }
2101
2102 return string.Join("\n", lines);
2103 }
2104
2129 private static string NormalizeImageBlockSpacing(string content)
2130 {
2131 var text = Regex.Replace(
2132 content.Replace("\r\n", "\n"),
2133 @"[ \t]*(<img\b)",
2134 "\n$1",
2135 RegexOptions.IgnoreCase);
2136 var lines = text.Split('\n');
2137 var normalized = new List<string>();
2138 var needsBlankBeforeNextContent = false;
2139
2140 foreach (var raw in lines)
2141 {
2142 var line = raw.TrimEnd();
2143 var isImage = line.TrimStart().StartsWith("<img", StringComparison.OrdinalIgnoreCase);
2144
2145 if (isImage)
2146 {
2147 AddBlankLineIfNeeded(normalized);
2148 normalized.Add(line.Trim());
2149 needsBlankBeforeNextContent = true;
2150 continue;
2151 }
2152
2153 if (needsBlankBeforeNextContent && line.Trim().Length > 0)
2154 {
2155 AddBlankLineIfNeeded(normalized);
2156 needsBlankBeforeNextContent = false;
2157 }
2158 else if (line.Trim().Length == 0)
2159 {
2160 needsBlankBeforeNextContent = false;
2161 }
2162
2163 normalized.Add(line);
2164 }
2165
2166 return string.Join("\n", normalized).Trim();
2167 }
2168
2185 private static void AddBlankLineIfNeeded(List<string> lines)
2186 {
2187 if (lines.Count > 0 && lines[^1].Trim().Length > 0)
2188 lines.Add("");
2189 }
2190
2215 private static List<string> TryExpandCollapsedPipeTable(string line)
2216 {
2217 var trimmed = line.Trim();
2218 if (!trimmed.Contains('|') || !trimmed.Contains("---", StringComparison.Ordinal))
2219 return [];
2220
2221 var cells = trimmed
2222 .Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
2223 .Where(c => !string.IsNullOrWhiteSpace(c))
2224 .ToList();
2225
2226 var firstSeparator = cells.FindIndex(c => c.TrimStart().StartsWith("---", StringComparison.Ordinal));
2227 if (firstSeparator < 2) return [];
2228
2229 var columnCount = firstSeparator;
2230 if (columnCount < 2 || cells.Count < columnCount * 3)
2231 return [];
2232
2233 var separator = cells.Skip(columnCount).Take(columnCount).ToList();
2234 if (separator.Count != columnCount || separator.Any(c => !c.TrimStart().StartsWith("---", StringComparison.Ordinal)))
2235 return [];
2236
2237 var header = cells.Take(columnCount).Select(c => c.Trim()).ToArray();
2238 var values = cells.Skip(columnCount * 2).Select(c => c.Trim()).ToList();
2239 var dataRows = values
2240 .Chunk(columnCount)
2241 .Where(row => row.Length == columnCount)
2242 .Select(row => row.Select(c => c.Trim()).ToArray())
2243 .ToList();
2244
2245 if (dataRows.Count == 0)
2246 return [];
2247
2248 var result = new List<string>
2249 {
2250 "| " + string.Join(" | ", header) + " |",
2251 "| " + string.Join(" | ", Enumerable.Range(0, columnCount).Select(idx => idx == 1 ? "---:" : "---")) + " |"
2252 };
2253 result.AddRange(dataRows.Select(row => "| " + string.Join(" | ", row) + " |"));
2254 return result;
2255 }
2256
2281 private static List<string> SplitTableLine(string line) =>
2282 line.Contains('\t')
2283 ? line.Split('\t', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList()
2284 : Regex.Split(line.Trim(), @"\s{2,}")
2285 .Where(c => !string.IsNullOrWhiteSpace(c))
2286 .Select(c => c.Trim())
2287 .ToList();
2288
2313 private static string NormalizeIngredientLines(string content)
2314 {
2315 var lines = content.Replace("\r\n", "\n").Split('\n');
2316 var inIngredients = false;
2317 var builder = new StringBuilder();
2318
2319 foreach (var raw in lines)
2320 {
2321 var line = raw;
2322 var trimmed = line.Trim();
2323
2324 if (trimmed.StartsWith("### 재료:", StringComparison.Ordinal))
2325 {
2326 inIngredients = true;
2327 builder.AppendLine(line);
2328 continue;
2329 }
2330
2331 if (inIngredients && trimmed.StartsWith("### ", StringComparison.Ordinal))
2332 inIngredients = false;
2333
2334 if (inIngredients &&
2335 trimmed.Length > 0 &&
2336 !trimmed.StartsWith("-", StringComparison.Ordinal) &&
2337 Regex.IsMatch(trimmed, @"\d+(\.\d+)?\s*(g|kg|ml|l|개|큰술|작은술|컵|cm)\b", RegexOptions.IgnoreCase))
2338 {
2339 line = "- " + trimmed;
2340 }
2341
2342 builder.AppendLine(line);
2343 }
2344
2345 return builder.ToString().TrimEnd();
2346 }
2347
2356 private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
2357
2358 // HTTP 검증 없이 신뢰하는 도메인 — 실제 이미지 파일만 제공하는 공식 CDN
2367 private static readonly string[] _trustedImageHosts =
2368 [
2369 "upload.wikimedia.org",
2370 "images.unsplash.com",
2371 "images.pexels.com",
2372 "cdn.imweb.me",
2373 "tong.visitkorea.or.kr",
2374 "korean.visitkorea.or.kr",
2375 "cdn.visitkorea.or.kr",
2376 ];
2377
2402 private static bool IsPlausibleImageUrl(string url)
2403 {
2404 if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
2405 return false;
2406 if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
2407 return false;
2408
2409 var value = url.Trim();
2410 if (value.Contains("...", StringComparison.Ordinal) ||
2411 value.Contains("{", StringComparison.Ordinal) ||
2412 value.Contains("}", StringComparison.Ordinal) ||
2413 value.Contains("[", StringComparison.Ordinal) ||
2414 value.Contains("]", StringComparison.Ordinal))
2415 return false;
2416
2417 return true;
2418 }
2419
2444 private static bool IsLikelyDirectImageUrl(string url)
2445 {
2446 if (!IsPlausibleImageUrl(url) || !Uri.TryCreate(url, UriKind.Absolute, out var uri))
2447 return false;
2448
2449 var path = uri.AbsolutePath;
2450 if (Regex.IsMatch(path, @"\.(jpe?g|png|webp|gif|bmp)(/)?$", RegexOptions.IgnoreCase))
2451 return true;
2452
2453 var host = uri.Host.ToLowerInvariant();
2454 return host.Contains("upload.wikimedia.org", StringComparison.Ordinal) ||
2455 host.Contains("images.unsplash.com", StringComparison.Ordinal) ||
2456 host.Contains("images.pexels.com", StringComparison.Ordinal) ||
2457 host.Contains("cdn.imweb.me", StringComparison.Ordinal);
2458 }
2459
2500 private static async Task<List<string>> ValidateImageUrlsAsync(List<string> urls, Action<string>? onStatus = null, ISet<string>? excludeUrls = null)
2501 {
2502 var valid = new List<string>();
2503 foreach (var url in urls)
2504 {
2505 if (!IsPlausibleImageUrl(url))
2506 continue;
2507 if (excludeUrls != null && excludeUrls.Contains(url))
2508 continue;
2509
2510 try
2511 {
2512 // 신뢰 도메인 + 이미지 확장자 → HTTP 검증 생략
2513 if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
2514 {
2515 var host = uri.Host.ToLowerInvariant();
2516 var path = uri.AbsolutePath;
2517 bool trustedHost = _trustedImageHosts.Any(h => host.Contains(h, StringComparison.Ordinal));
2518 bool imageExt = Regex.IsMatch(path, @"\.(jpe?g|png|webp|gif|bmp)$", RegexOptions.IgnoreCase);
2519 if (trustedHost && imageExt)
2520 {
2521 valid.Add(url);
2522 continue;
2523 }
2524 }
2525
2526 onStatus?.Invoke($"🔍 {url[..Math.Min(60, url.Length)]}");
2527 if (await IsReachableImageAsync(url, HttpMethod.Head) ||
2528 await IsReachableImageAsync(url, HttpMethod.Get))
2529 {
2530 valid.Add(url);
2531 }
2532 }
2533 catch { }
2534 }
2535 return valid;
2536 }
2537
2570 private static async Task<bool> IsReachableImageAsync(string url, HttpMethod method)
2571 {
2572 try
2573 {
2574 using var req = new HttpRequestMessage(method, url);
2575 req.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
2576 using var res = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
2577 if (!res.IsSuccessStatusCode) return false;
2578 // Content-Type이 있으면 image/* 인지 확인, 없으면 2xx 성공만으로 통과
2579 var ct = res.Content.Headers.ContentType?.MediaType;
2580 return ct == null || ct.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
2581 }
2582 catch
2583 {
2584 return false;
2585 }
2586 }
2587
2612 public static string BuildTravelPrompt(IReadOnlyList<string> existingTitles) =>
2613 $$"""
2614 {앨범} 주제로 한국 가족 나들이·여행 블로그 포스트 1개를 작성해줘.
2615 아래 형식 그대로만 출력해줘. ===섹션=== 구분자는 반드시 유지하고, 그 외 설명은 쓰지 마.
2616 코드블록(```), JSON, 주석, 머리말, 맺음말을 절대 쓰지 마.
2617
2618 AutoWriter 저장 계약:
2619 - 이 응답은 Families.Web App_Data/Family/{선택슬러그}/posts/*.json 으로 파싱 저장된다.
2620 - 섹션 태그는 정확히 ===TITLE===, ===CONTENT===, ===IMAGES===, ===VIDEO=== 만 사용한다.
2621 - 모든 [대괄호 안내문]은 실제 값으로 바꾼다. 대괄호 안내문을 그대로 남기지 마.
2622 - TITLE은 파일 목록에 보이는 글 제목이므로 한 줄만 작성한다.
2623 - CONTENT는 Markdown 본문이며, 앱이 그대로 렌더링한다.
2624
2625 작성 조건:
2626 - 실제 국내 장소 1곳을 정한다 (실내·실외 모두 가능).
2627 - 정보는 실제 공식 사이트나 현장 기준으로 구체적으로 적는다.
2628 - 확인이 불가한 정보는 "방문 전 공식 확인 필요"로 표시한다.
2629 - 아이 동반 가족 시선에서 실용적으로 쓴다 (유모차, 수유실, 주차, 대기 등).
2630 - 비용 표는 성인 2명 + 아이 1명 기준으로 작성한다.
2631 - 여행 경비 예상 표는 반드시 Markdown 표로 출력한다. 표 전체를 한 줄에 붙여 쓰지 말고, 헤더/구분선/각 항목 행을 각각 줄바꿈한다.
2632
2633 이미지 조건:
2634 - IMAGES 섹션에 실제 접근 가능한 이미지 URL을 6개 출력한다. "없음" 금지. (많이 줄수록 좋음 — 검증 실패 대비 여유분)
2635 - 각 URL은 http 또는 https로 시작하는 이미지 직접 URL이어야 한다.
2636 - URL 줄에는 설명, 번호, 마크다운 이미지 문법을 붙이지 말고 URL만 쓴다.
2637 - 실제 브라우저에서 열리는 직접 이미지 URL만 사용한다. 엑박이 뜰 수 있는 URL은 쓰지 않는다.
2638 - URL은 가능하면 .jpg, .jpeg, .png, .webp 로 끝나는 주소를 고른다.
2639 - URL에 공백, 한글, 괄호, 따옴표가 있으면 반드시 퍼센트 인코딩된 정상 URL로 출력한다.
2640 - 블로그 글 페이지, 검색 결과 페이지, redirect URL, 썸네일 페이지 URL은 금지한다.
2641 - 가로 1000px 이상 선명한 대표 이미지를 고른다. 작은 썸네일, 흐릿한 사진, 야간 저화질 사진은 금지.
2642 - 해당 장소 공식 사이트의 큰 이미지 또는 장소 안내 이미지/지도/전시 이미지를 1순위로 사용한다.
2643 - 공식 이미지가 작거나 흐리면 Wikimedia, 공공기관 보도자료, Unsplash/Pexels의 실제 큰 이미지로 대체한다.
2644 - 이미지 URL은 반드시 해당 장소 자체를 보여줘야 한다. 장소와 무관한 교통수단, 음식, 사람, 일반 풍경 사진은 금지한다.
2645 - Wikimedia를 쓸 때는 upload.wikimedia.org의 실제 파일 URL을 사용한다. commons.wikimedia.org/wiki/... 페이지 URL은 금지한다.
2646 - Unsplash/Pexels를 쓸 때도 실제 검색으로 확인된 이미지 URL만 쓴다. photo-{실제ID}, {id}, ... 같은 템플릿 URL은 절대 쓰지 않는다.
2647 - CONTENT 안 <img> 태그 src에도 같은 URL을 그대로 사용한다.
2648
2649 유튜브:
2650 - 기본값은 "없음"이다.
2651 - YouTube를 넣어야 한다면 embed 재생이 허용되는 공식 채널/공공기관 영상 1개만 사용한다.
2652 - 개인 방문 영상, Shorts, 광고성 영상, iframe 재생 차단 가능성이 큰 영상은 쓰지 말고 "없음"만 쓴다.
2653
2654 {{(existingTitles.Count > 0 ? $"이미 작성된 장소 목록 (반드시 제외, 절대 반복 금지):\n{string.Join("\n", existingTitles.TakeLast(80).Select(t => $"- {t}"))}\n" : "")}}
2655 ===TITLE===
2656 실제 장소명, 아이 동반 핵심 팁 또는 주의사항
2657
2658 ===CONTENT===
2659
2660 ## 한눈에 보는 포인트
2661
2662 이 장소가 아이 동반 가족에게 좋은 이유를 2~3줄로 작성한다.
2663 이동 동선, 체류 시간, 날씨 조건 등 실용 정보 위주로 쓴다.
2664
2665 <img class="fa-inline-photo" src="[IMAGES 첫 번째 URL]" alt="[장소 대표 이미지 설명]">
2666
2667 ## 이런 가족에게 추천
2668
2669 아이 연령대, 이동 수단, 부모 성향 기준으로 추천 대상을 구체적으로 작성한다.
2670
2671 ## 아이와 좋았던 점
2672
2673 아이가 지루하지 않을 요소, 실제 체험 동선, 부모가 편했던 포인트를 작성한다.
2674 유모차 이용 여부, 수유실/화장실 위치, 그늘/쉬는 공간도 포함한다.
2675
2676 <img class="fa-inline-photo" src="[IMAGES 두 번째 URL]" alt="[장소 내부 또는 체험 이미지 설명]">
2677
2678 ## 동선과 시간표
2679
2680 도착부터 출발까지 시간 흐름에 맞게 이동 순서를 작성한다.
2681 구체적인 시간대(예: 11시 도착, 12시 점심)와 이유를 함께 쓴다.
2682
2683 ## 여행 경비 예상
2684
2685 | 항목 | 예상 비용 | 메모 |
2686 | --- | ---: | --- |
2687 | 입장료 | [실제 금액 또는 무료] | 성인/아이 구분 |
2688 | 주차 | [실제 금액] | 시간당 기준 |
2689 | 식사 | [실제 금액] | 가족 3인 기준 |
2690 | 간식 | [실제 금액] | 선택 항목 |
2691 | 합계 | [실제 금액] | 주차 할인 전 기준 |
2692
2693 <img class="fa-inline-photo" src="[IMAGES 세 번째 URL]" alt="[주변 풍경 또는 식사 관련 이미지 설명]">
2694
2695 ## 주차·화장실·유모차 포인트
2696
2697 주차 위치와 요금, 화장실과 수유실 위치, 유모차 반입/대여 여부를 구체적으로 쓴다.
2698
2699 ## 준비물과 주의할 것
2700
2701 챙기면 좋은 것, 주의해야 할 것을 항목별로 작성한다.
2702
2703 ## 방문 전 확인할 것
2704
2705 운영시간, 예약 여부, 계절적 제한, 바뀔 수 있는 정보를 명시한다.
2706
2707 ## 총평
2708
2709 가족 나들이 장소로서 솔직한 평가를 2~3줄로 작성한다. 추천 대상과 비추 상황도 포함한다.
2710
2711 ===IMAGES===
2712 https://...
2713 https://...
2714 https://...
2715
2716 ===VIDEO===
2717 없음
2718 """;
2719
2735 /// </param>
2736 /// <returns>
2737 /// \if KO
2738 /// <para>Build Cooking Prompt 작업에서 생성한 <c>string</c> 결과입니다.</para>
2739 /// \endif
2740 /// \if EN
2741 /// <para>The <c>string</c> result produced by the build cooking prompt operation.</para>
2742 /// \endif
2743 /// </returns>
2744 public static string BuildCookingPrompt(IReadOnlyList<string> existingTitles) =>
2745 BuildCookingPrompt(existingTitles, (null, 0));
2746
2762 /// </param>
2763 /// <returns>
2764 /// \if KO
2765 /// <para>Build Cooking Timeline Instruction 작업에서 생성한 <c>string</c> 결과입니다.</para>
2766 /// \endif
2767 /// \if EN
2768 /// <para>The <c>string</c> result produced by the build cooking timeline instruction operation.</para>
2769 /// \endif
2770 /// </returns>
2771 private static string BuildCookingTimelineInstruction((DateTime? LastPostedAt, int LastNumber) timeline)
2772 {
2773 var nextAt = GetNextCookingSlot(timeline.LastPostedAt);
2774 var nextNumber = timeline.LastNumber > 0 ? timeline.LastNumber + 1 : 1;
2775 return $"""
2776 타임라인 강제 조건:
2777 - 마지막 생성물 이후에만 이어서 작성한다.
2778 - 이번 DETAIL 번호는 반드시 {nextNumber}로 쓴다.
2779 - 이번 식사구분은 반드시 {GetCookingMealName(nextAt)}로 쓴다.
2780 - 이번 날짜는 반드시 {nextAt:yyyy년 M월 d일}로 쓴다.
2781 - TITLE, DATE, CONTENT의 식사구분과 날짜가 위 조건과 어긋나면 안 된다.
2782 """;
2783 }
2784
2808 /// </param>
2809 /// <returns>
2810 /// \if KO
2811 /// <para>Build Cooking Prompt 작업에서 생성한 <c>string</c> 결과입니다.</para>
2812 /// \endif
2813 /// \if EN
2814 /// <para>The <c>string</c> result produced by the build cooking prompt operation.</para>
2815 /// \endif
2816 /// </returns>
2817 public static string BuildCookingPrompt(IReadOnlyList<string> existingTitles, (DateTime? LastPostedAt, int LastNumber) timeline) =>
2818 $$"""
2819 {앨범} 주제로 1983년 한국 가족 집밥·육아 기록 블로그 포스트를 1개 작성해줘.
2820 아래 형식 그대로만 출력해줘. ===섹션=== 구분자는 반드시 유지하고, 다른 설명은 쓰지 마.
2821 코드블록(```), JSON, 주석, 머리말, 맺음말을 절대 쓰지 마.
2822
2823 AutoWriter 저장 계약:
2824 - 이 응답은 Families.Web App_Data/Family/{선택슬러그}/posts/*.json 으로 파싱 저장된다.
2825 - AutoWriter는 ===IMAGES===의 URL 1개와 ===DETAIL_NNN_TITLE===, ===DETAIL_NNN_DATE===, ===DETAIL_NNN_CONTENT=== 섹션만 실제 포스트로 저장한다.
2826 - DETAIL 번호 NNN은 숫자만 사용한다. 예: 231, 232, 233.
2827 - 이미 작성된 제목 목록에 있는 번호(#숫자), 식사구분, 메뉴명, 비슷한 메뉴는 절대 다시 쓰지 않는다.
2828 - 기존 제목에 "#220"이 있으면 새 제목은 #220을 절대 쓰지 말고, 타임라인 강제 조건의 새 번호만 사용한다.
2829 - 기존 메뉴가 "생선구이"면 고등어구이, 갈치구이, 조기구이처럼 거의 같은 생선구이류도 피한다.
2830 - 기존 메뉴가 "김밥"이면 주먹밥, 유부초밥처럼 같은 도시락/밥말이류 반복도 피한다.
2831 - ALBUMS, TIMELINE, 표 형태 요약은 출력하지 마. 저장되지 않으므로 금지.
2832 - 모든 [대괄호 안내문]은 실제 값으로 바꾼다. 대괄호 안내문을 그대로 남기지 마.
2833 - TITLE은 파일 목록에 보이는 글 제목이므로 한 줄만 작성한다.
2834 - CONTENT는 Markdown 본문이며, 앱이 그대로 렌더링한다.
2835 - CONTENT 안의 제목은 반드시 ##, ###, -, 1. 마크다운 문법을 유지한다.
2836
2837 글쓰기 톤:
2838 - 아기 시점("저는 아직 수유만 해요"), 따뜻하고 구체적인 1983년 가정집 분위기
2839 - 할머니, 아버지, 어머니가 등장하고 각자의 행동과 대화를 자연스럽게 묘사한다
2840 - 도입부는 그날의 계절·날씨·집 분위기로 시작한다 (첫 문단, 섹션 헤더 없이)
2841
2842 메뉴 선택:
2843 - 아침/점심/저녁 중 하나를 고르고, TITLE과 본문 전체에서 같은 식사구분을 유지한다.
2844 - 1983년 한국 집밥 제철 음식으로 쓴다.
2845 - 날짜는 1983년 중 자유롭게 (매 실행마다 다르게)
2846 - 아기에게 직접 먹이는 음식인지 어른 밥상인지 반드시 구분
2847 - TITLE의 식사구분(아침/점심/저녁), 메뉴명, 본문 음식, 이미지 음식은 반드시 서로 일치한다.
2848 - 이번 메뉴는 이미 작성된 메뉴 목록과 겹치지 않는 새 메뉴여야 한다.
2849 - 같은 주재료 반복을 피한다. 최근 목록에 생선 메뉴가 많으면 두부/나물/국/찌개/전/조림/볶음 등 다른 계열로 바꾼다.
2850
2851 레시피 작성 기준 (초보자가 실제로 따라 만들 수 있어야 함):
2852 - 재료는 g, ml, cm 단위로 정확히
2853 - 불 세기(약불/중불/강불)와 조리 시간(분 단위)을 명시
2854 - 익었는지 확인하는 기준을 눈으로 보이는 변화로 설명
2855 - "약간", "적당히", "노릇하게"만 쓰지 말고 반드시 기준 수치를 함께 적는다
2856 - 간이 맞지 않을 때, 탈 것 같을 때 복구 방법 포함
2857
2858 이미지 조건:
2859 - IMAGES 섹션에 메뉴 음식 사진 URL을 6개 출력한다. (검증 실패 대비 여유분)
2860 - 각 URL은 http 또는 https로 시작하는 이미지 직접 URL이어야 한다.
2861 - URL 줄에는 설명, 번호, 마크다운 이미지 문법을 붙이지 말고 URL만 쓴다.
2862 - 메뉴와 다른 음식 사진 금지. 예: 고등어조림 글에 샐러드/덮밥/피자 사진 금지.
2863 - 가로 1000px 이상 선명한 음식 사진만 사용한다. 작은 썸네일, 흐릿한 사진, 생성 이미지 느낌의 사진 금지.
2864 - 실제 브라우저에서 열리는 직접 이미지 URL만 사용한다. 엑박이 뜰 수 있는 URL은 쓰지 않는다.
2865 - URL은 가능하면 .jpg, .jpeg, .png, .webp 로 끝나는 주소를 고른다.
2866 - 우선순위는 upload.wikimedia.org, images.unsplash.com, images.pexels.com 같은 안정적인 이미지 CDN이다.
2867 - 블로그 글 페이지, 검색 결과 페이지, commons.wikimedia.org/wiki/... 페이지, redirect URL, 썸네일 페이지 URL은 금지한다.
2868 - URL에 공백, 한글, 괄호, 따옴표가 있으면 반드시 퍼센트 인코딩된 정상 URL로 출력한다.
2869 - Unsplash/Pexels/Wikimedia/공공 이미지 중 실제 존재하는 직접 이미지 URL만 사용. 가짜 URL 절대 금지.
2870 - 메뉴 사진을 확실히 찾기 어렵다면 그 메뉴를 쓰지 말고, 사진을 확실히 찾을 수 있는 한국 집밥 메뉴로 바꾼다.
2871 - Wikimedia를 쓸 때는 upload.wikimedia.org의 실제 파일 URL을 사용한다. commons.wikimedia.org/wiki/... 페이지 URL은 금지한다.
2872 - Unsplash/Pexels를 쓸 때도 실제 검색으로 확인된 이미지 URL만 쓴다. photo-{실제ID}, {id}, ... 같은 템플릿 URL은 절대 쓰지 않는다.
2873 - DETAIL CONTENT 안에는 <img> 태그를 넣지 마. 이미지는 AutoWriter가 글 상단 미디어로 따로 표시한다.
2874
2875 유튜브: 출력하지 마. VIDEO 섹션에는 항상 "없음"만 쓴다.
2876
2877 {{(existingTitles.Count > 0 ? $"이미 작성된 메뉴 목록 (반드시 제외, 절대 반복 금지):\n{string.Join("\n", existingTitles.TakeLast(120).Select(t => $"- {t}"))}\n" : "")}}
2878 {{BuildCookingTimelineInstruction(timeline)}}
2879
2880 ===DETAIL_[새숫자]_TITLE===
2881 [1983 집밥육아 #새숫자] [아침/점심/저녁] [메뉴명]과 [아기 상태 한 줄]
2882
2883 ===DETAIL_[새숫자]_DATE===
2884 1983년 [날짜]
2885
2886 ===DETAIL_[새숫자]_CONTENT===
2887 [그날의 집 분위기·계절·가족 동선을 자연스럽게 묘사하는 도입 2~3문장. 헤더 없이.]
2888
2889 ## 오늘의 아기 밥
2890
2891 [아기 기록. 수유 또는 이유식 상태를 명확히. 어른 반찬을 직접 먹지 않았음을 명시. 2~3문단.]
2892
2893 ## 오늘의 가족 집밥
2894
2895 [가족 식사 분위기와 음식 이야기. 할머니·아버지·어머니 각자의 모습 묘사. 2~3문단.]
2896
2897 ## 만드는 법
2898
2899 ### 재료: 어른 2~3인분
2900
2901 - [재료명] [정확한 수량 g/ml/개]
2902 - [재료명] [정확한 수량]
2903
2904 ### 손질하기
2905
2906 1. [씻는 방법 구체적으로]
2907 2. [몇 cm 크기로 자르는지]
2908 3. [초보자가 실수하기 쉬운 부분]
2909
2910 ### [조리 방법에 맞는 제목 (볶기/끓이기/부치기 등)]
2911
2912 1. [팬/냄비 예열 시간과 불 세기]
2913 2. [재료 넣는 순서, 불 세기, 시간]
2914 3. [익었는지 확인하는 눈에 보이는 기준]
2915 4. [실패 시 복구 방법]
2916
2917 [간이 짤 때/싱거울 때/탈 것 같을 때 복구 방법을 자연스럽게 이어 쓴다. 헤더 없이 1~2문장.]
2918
2919 ## 별점
2920
2921 [★ 4~5개]
2922
2923 ## 파워블로그 한줄평
2924
2925 [한 문장. 그날의 분위기가 담긴 문장.]
2926
2927 ===IMAGES===
2928 https://...
2929 https://...
2930 https://...
2931
2932 ===VIDEO===
2933 없음
2934 """;
2950
2951
2952 private static string DetectDefaultRoot()
2953 {
2954 foreach (var c in new[]
2955 {
2956 @"D:\Work\Dreamine.MVVM.FullKit\20_SOURCES\000. Project\010. App\Families.Web\bin\Release\net8.0-windows\publish\App_Data",
2957 @"D:\Work\Dreamine.MVVM.FullKit\20_SOURCES\000. Project\010. App\Families.Web\bin\Debug\net8.0-windows\App_Data",
2958 Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\..\..\..\Families.Web\bin\Release\net8.0-windows\publish\App_Data"),
2959 Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\..\..\..\Families.Web\bin\Debug\net8.0-windows\App_Data"),
2960 })
2961 {
2962 try { var f = Path.GetFullPath(c); if (Directory.Exists(Path.Combine(f, "Family"))) return f; }
2963 catch { }
2964 }
2965 return "";
2966 }
2967}
record WaitOption(int Seconds, string Label)
record IntervalOption(int Minutes, string Label)
static string BuildInjectScript(string prompt)
ObservableCollection< string > Slugs
WriterSession(string? initialPrompt=null)
static DateTime GetNextCookingSlot(DateTime? lastPostedAt)
readonly System.Windows.Threading.Dispatcher _uiDispatcher
readonly PromptHistoryService _history
static async Task< bool > IsReachableImageAsync(string url, HttpMethod method)
static int ExtractCookingNumberFromTitle(string title)
static string NormalizeCookingTitle(string title, int number, string mealName)
static List< string > ExtractImageUrls(string content)
static string NormalizeTravelContent(string content)
static string GetSection(string text, string tag)
async Task< List< PostEntry > > ParseCookingResponseAsync(string? raw)
static string NormalizeIngredientLines(string content)
static string BuildTravelPrompt(IReadOnlyList< string > existingTitles)
static async Task< List< string > > ValidateImageUrlsAsync(List< string > urls, Action< string >? onStatus=null, ISet< string >? excludeUrls=null)
static List< string > SplitTableLine(string line)
static string RemoveInvalidInlineImages(string content, IReadOnlyCollection< string > allowedUrls)
record AiPostResult(string Title, string Content, List< string > Photos, List< string > Videos)
async Task RunLoopCycleAsync(CancellationToken ct)
static List< string > TryExpandCollapsedPipeTable(string line)
static string NormalizeCookingContent(string content)
async Task LoopWorkerAsync(CancellationToken ct)
static string GetCookingMealName(DateTime postedAt)
Func< string, Task< string?> >? ExecuteScriptAsync
readonly HashSet< string > _usedImageUrls
static string NormalizeKnownHeadings(string content, IReadOnlyDictionary< string, string > headings)
static string BuildCookingTimelineInstruction((DateTime? LastPostedAt, int LastNumber) timeline)
static ? AiPostResult ParseTravelResponse(string? raw)
static string NormalizeSimpleTables(string content)
ObservableCollection< AlbumInfo > Albums
static string BuildCookingPrompt(IReadOnlyList< string > existingTitles)
static string NormalizeImageBlockSpacing(string content)
static List< string > MergeUrls(IEnumerable< string > primary, IEnumerable< string > secondary)
static string SubstituteImages(string content, IList< string > urls, int offset=0)
static void AddBlankLineIfNeeded(List< string > lines)