**REGION 00 サイト設定・取得方式(fetch並行/click直列) # BaseUrl : 検索画面の URL # SearchInputSelector : ID を入れる検索欄の CSS セレクター(F12 → 要素を右クリック → Copy selector) # SearchButtonSelector : 検索ボタンの CSS セレクター。空なら検索欄で Enter Variables.ConvertJsonToCustomObject Json: $'''{ "DownloadMethod": "fetch", "BrowserDownloadFolder": "", "BaseUrl": "http://127.0.0.1:8765/?transport=navigate", "SearchInputSelector": "#idNumberInput", "SearchButtonSelector": "#idSearchButton" }''' CustomObject=> SiteIn **ENDREGION **REGION 01 文章とフォーム初期値(文言・初期値を変えたいときはここ。数値の設定は REGION 02) # 初期_… はフォームに最初から入っている値。空なら空欄(保存先が空なら <ユーザー>\Downloads)。※下の初期値は模擬サイト用。会社では空にする # {ID} {日時} {ユーザー} {ワード} {名前} {理由} {件数} は実行時に置き換わる。ログの各行は先頭の空白も含めてそのまま使われる Variables.ConvertJsonToCustomObject Json: $'''{ "初期_ID": "DEMO-001\nDEMO-002\nDEMO-ERROR", "初期_検索ワード": "検査報告書\n該当なし確認", "初期_保存先": "", "画面_入力タイトル": "PDF取得", "画面_確認見出し": "ステップ1 実行前の確認(両方にチェックしないと進めません)", "画面_確認ログイン": "Edge で対象サイトにログイン済みである", "画面_確認モード": "接続先(上に表示)が、今回実行したいサイト(本番/テスト)である", "画面_確認未了": "確認の2項目にチェックを入れてください。", "画面_入力見出し": "ステップ2 入力", "画面_入力説明": "IDナンバーは空白・改行区切り(Excel の範囲貼り付け可)。検索ワードは1行に1つ。リンク文字にワードを含む PDF を取得します。保存先は任意のフォルダー(無ければ作成)。", "画面_ID欄": "IDナンバー(最大50件)", "画面_ワード欄": "検索ワード(1行1件・最大20件)", "画面_保存先欄": "最終保存先フォルダー(click方式の受け取り先はREGION 00で指定。この中に PDF-Results\\\\PAD_Hierarchy_実行時刻 を作ります)", "画面_開始": "取得開始", "画面_キャンセル": "キャンセル", "画面_ログインタイトル": "ログインしてください", "画面_ログイン本文": "ブラウザーで検索画面が表示されていません。ログインして検索画面を開いてから OK を押してください。", "画面_結果タイトル": "PDF取得|全体の結果", "画面_結果ログ欄": "全体の結果(保存先の 00_全体の結果.txt と同じ内容)", "画面_結果案内": "「保存先を開く」はフォルダーを開くだけで、この画面は残ります。終わるときは「閉じる」。", "画面_保存先を開く": "保存先を開く", "画面_閉じる": "閉じる", "画面_中断タイトル": "PDF取得|中断", "画面_中断本文": "ブラウザーまたはログインを確認できませんでした。取得は行っていません。", "ログ_区切り": "=========================================================", "ログ_ID見出し": "【{ID}】 ProcessStart={日時} UserName={ユーザー}", "ログ_ワード見出し": " [検索ワード] {ワード}", "ログ_成功": " 〇 成功 | {名前}", "ログ_失敗": " X 失敗 | {名前}({理由})", "ログ_一致なし": " ― 一致するPDFなし(一覧は取得済み。このワードを含むPDFが無い)", "ログ_検索結果0件": " [検索結果] 0件(検索は完了。この ID の PDF は一覧にありません)", "ログ_ページ読取失敗": " (。•́ - •̀。) ページ読取失敗 (一覧取得エラーまたは待機時間超過)", "ログ_記録失敗注記": " (注意)結果の記録に失敗した件数:{件数}。全体ログの件数と保存先の実ファイルを確認してください", "ログ_全体ファイル名": "00_全体の結果.txt", "ログ_全体見出し": "[PDF取得 全体の結果]", "ログ_全体注記": "※ 該当なし=一覧は取れたがワードに合うPDFが無い(検索ワード単位)。ページ読取失敗=一覧そのものが取れない(ID単位)。" }''' CustomObject=> Msg Variables.ConvertCustomObjectToJson CustomObject: Msg Json=> MsgJson **ENDREGION **REGION 02 固定値(普通は触らない) # ArrivalText / FailedSelector はサイトに「結果が出た印」「失敗の印」があるときだけ入れる。空でも動く(空だとリンクが出ないページは待機上限まで待つ) # ↓ いまは模擬サイト用の値。会社サイトでは両方 "" にする(印が分かれば入れる) # ResultWaitSeconds : 検索結果を待つ上限(実時間)。FetchTimeoutSeconds : PDF 1件の受信上限。CollectWaitSeconds : 2周目で ID の受信完了を待つ上限(予約時刻から) # MaxParallelFetch : 全 ID 共通で同時に受信する PDF の数。FetchStartGapMs : 受信開始の間隔。MaxBufferMegabytes : 未保存の受信データがこれを超えたら次の受信を待つ Variables.ConvertJsonToCustomObject Json: $'''{ "SearchUrlTemplate": "", "ResultAreaSelector": "body", "LinkMustMatch": "[.]pdf$", "ArrivalText": "一覧表示完了", "FailedSelector": "html[data-search-error]:not([data-search-error=\'\'])", "StableSeconds": 1.5, "PollSeconds": 0.5, "ResultWaitSeconds": 30, "FetchTimeoutSeconds": 60, "CollectWaitSeconds": 180, "MaxPdfMegabytes": 30, "MaxParallelFetch": 5, "FetchStartGapMs": 300, "MaxBufferMegabytes": 200 }''' CustomObject=> Site SET Site['DownloadMethod'] TO SiteIn['DownloadMethod'] SET Site['BrowserDownloadFolder'] TO SiteIn['BrowserDownloadFolder'] SET Site['BaseUrl'] TO SiteIn['BaseUrl'] SET Site['SearchInputSelector'] TO SiteIn['SearchInputSelector'] SET Site['SearchButtonSelector'] TO SiteIn['SearchButtonSelector'] Variables.ConvertCustomObjectToJson CustomObject: Site Json=> SiteJson **ENDREGION **REGION 03 入力 System.GetEnvironmentVariable.GetEnvironmentVariable Name: $'''USERPROFILE''' Value=> ProfileFolder System.GetEnvironmentVariable.GetEnvironmentVariable Name: $'''USERNAME''' Value=> UserName System.GetEnvironmentVariable.GetEnvironmentVariable Name: $'''USERDOMAIN''' Value=> UserDomain SET RunUser TO $'''%UserDomain%\\%UserName%''' Variables.ConvertJsonToCustomObject Json: $'''{}''' CustomObject=> Who SET Who['user'] TO RunUser Variables.ConvertCustomObjectToJson CustomObject: Who Json=> WhoJson SET OutputRoot TO Msg['初期_保存先'] IF IsEmpty(OutputRoot) THEN SET OutputRoot TO $'''%ProfileFolder%\\Downloads''' END SET IdText TO Msg['初期_ID'] SET WordText TO Msg['初期_検索ワード'] SET InputNotice TO Msg['画面_入力説明'] LABEL ShowInput Display.ShowCustomDialog CardTemplateJson: '''{ "type": "AdaptiveCard", "version": "1.4", "id": "AdaptiveCard", "body": [ { "type": "Container", "style": "emphasis", "bleed": true, "items": [ { "type": "TextBlock", "id": "Step1", "text": "${Step1_Text}", "wrap": true, "size": "Medium", "weight": "Bolder" }, { "type": "TextBlock", "id": "Target", "text": "接続先:${Site_Url}", "wrap": true }, { "type": "TextBlock", "id": "Selectors", "text": "検索欄 ${Sel_Input} / ボタン ${Sel_Button}", "wrap": true, "size": "Small", "isSubtle": true, "spacing": "None" }, { "type": "Input.Toggle", "id": "ChkLogin", "title": "${ChkLogin_Title}", "value": "false", "valueOn": "true", "valueOff": "false", "isRequired": true, "errorMessage": "チェックが必要です" }, { "type": "Input.Toggle", "id": "ChkMode", "title": "${ChkMode_Title}", "value": "false", "valueOn": "true", "valueOff": "false", "isRequired": true, "errorMessage": "チェックが必要です" } ] }, { "type": "TextBlock", "id": "Step2", "text": "${Step2_Text}", "wrap": true, "size": "Medium", "weight": "Bolder", "separator": true, "spacing": "ExtraLarge" }, { "type": "TextBlock", "id": "Notice", "text": "${Notice_Text}", "wrap": true }, { "type": "Input.Text", "id": "Ids", "label": "${Ids_Label}", "isMultiline": true, "maxLength": 4000, "value": "${Ids_Value}" }, { "type": "Input.Text", "id": "Words", "label": "${Words_Label}", "isMultiline": true, "maxLength": 2000, "value": "${Words_Value}" }, { "type": "Input.Text", "id": "Out", "label": "${Out_Label}", "value": "${Out_Value}", "isRequired": true, "maxLength": 1024 } ], "actions": [ { "type": "Action.Submit", "id": "OK", "title": "${Start_Title}" }, { "type": "Action.Submit", "id": "Cancel", "title": "${Cancel_Title}", "associatedInputs": "none" } ], "FormTitle": "${Form_Title}" }''' CustomFormData=> InputData ButtonPressed=> InputButton @Notice_Text: InputNotice @Ids_Value: IdText @Words_Value: WordText @Out_Value: OutputRoot @Ids_Label: Msg['画面_ID欄'] @Words_Label: Msg['画面_ワード欄'] @Out_Label: Msg['画面_保存先欄'] @Start_Title: Msg['画面_開始'] @Cancel_Title: Msg['画面_キャンセル'] @Form_Title: Msg['画面_入力タイトル'] @Step1_Text: Msg['画面_確認見出し'] @Site_Url: Site['BaseUrl'] @Sel_Input: Site['SearchInputSelector'] @Sel_Button: Site['SearchButtonSelector'] @ChkLogin_Title: Msg['画面_確認ログイン'] @ChkMode_Title: Msg['画面_確認モード'] @Step2_Text: Msg['画面_入力見出し'] ON ERROR GOTO Quit END IF InputButton <> $'''OK''' THEN EXIT Code: 0 END IF InputData['ChkLogin'] <> $'''true''' OR InputData['ChkMode'] <> $'''true''' THEN SET InputNotice TO $'''⚠ %Msg['画面_確認未了']%''' SET IdText TO InputData['Ids'] SET WordText TO InputData['Words'] SET OutputRoot TO InputData['Out'] GOTO ShowInput END SET IdText TO InputData['Ids'] SET WordText TO InputData['Words'] SET OutputRoot TO InputData['Out'] **ENDREGION **REGION 04 入力整形・保存先作成(PowerShell 1回) # 一時ファイル経由で受け渡す(引用符・長さの問題を避ける)。結果 JSON も同じファイルに書き戻す。 DateTime.GetCurrentDateTime.Local DateTimeFormat: DateTime.DateTimeFormat.DateAndTime CurrentDateTime=> RunStarted Text.ConvertDateTimeToText.FromCustomDateTime DateTime: RunStarted CustomFormat: $'''yyyyMMdd_HHmmss_fff''' Result=> RunStamp Variables.ConvertJsonToCustomObject Json: $'''{}''' CustomObject=> Carry SET Carry['ids'] TO IdText SET Carry['words'] TO WordText SET Carry['out'] TO OutputRoot SET Carry['stamp'] TO RunStamp Variables.ConvertCustomObjectToJson CustomObject: Carry Json=> CarryJson File.GetTempPath TempFile=> CarryFile File.WriteText File: CarryFile TextToWrite: CarryJson AppendNewLine: False IfFileExists: File.IfFileExists.Overwrite Encoding: File.FileEncoding.UTF8 Scripting.RunPowershellScript.RunScript Script: $'''$ErrorActionPreference = "Stop" $carrier = "%CarryFile%" $r = @{ ok = $false; message = ""; ids = @(); words = @(); runFolder = "" } try { $in = [IO.File]::ReadAllText($carrier, [Text.Encoding]::UTF8) | ConvertFrom-Json $ids = @(); $seen = @{} foreach ($p in ([regex]::Split([string]$in.ids, "\\s+"))) { $v = $p.Trim(); if ($v.Length -eq 0) { continue } if ($v -notmatch "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$") { throw "IDナンバーに使えない文字があります:$v" } if (-not $seen.ContainsKey($v)) { $seen[$v] = 1; $ids += $v } } $words = @(); $seen = @{} foreach ($p in ([regex]::Split([string]$in.words, "\\r\\n|\\r|\\n"))) { $v = $p.Trim(); if ($v.Length -eq 0) { continue } if ($v -match "[\\x00-\\x1f<>\\\\/:*?|]") { throw "検索ワードに使えない文字があります:$v" } if (-not $seen.ContainsKey($v)) { $seen[$v] = 1; $words += $v } } if ($ids.Count -lt 1 -or $ids.Count -gt 50) { throw "IDナンバーは1~50件にしてください(今 $($ids.Count) 件)" } if ($words.Count -lt 1 -or $words.Count -gt 20) { throw "検索ワードは1~20件にしてください(今 $($words.Count) 件)" } $out = [string]$in.out if (-not [IO.Path]::IsPathRooted($out)) { throw "保存先は絶対パスで指定してください" } [IO.Directory]::CreateDirectory($out) | Out-Null $out = [IO.Path]::Combine($out, "PDF-Results"); [IO.Directory]::CreateDirectory($out) | Out-Null $run = [IO.Path]::Combine($out, "PAD_Hierarchy_" + $in.stamp) if ([IO.Directory]::Exists($run)) { throw "同じ時刻の実行フォルダーがあります。少し待って再実行してください" } [IO.Directory]::CreateDirectory($run) | Out-Null $probe = [IO.Path]::Combine($run, ".write-test"); [IO.File]::WriteAllText($probe, "x"); [IO.File]::Delete($probe) $r.ok = $true; $r.ids = $ids; $r.words = $words; $r.runFolder = $run } catch { $r.message = $_.Exception.Message } [IO.File]::WriteAllText($carrier, ($r | ConvertTo-Json -Depth 4 -Compress), [Text.UTF8Encoding]::new($false))''' ScriptOutput=> PsOut ScriptError=> PsErr ON ERROR GOTO PrepFailed END File.ReadTextFromFile.ReadText File: CarryFile Encoding: File.TextFileEncoding.UTF8 Content=> PrepJson ON ERROR GOTO PrepFailed END Variables.ConvertJsonToCustomObject Json: PrepJson CustomObject=> Prep ON ERROR GOTO PrepFailed END File.Delete Files: CarryFile ON ERROR GOTO CarryDeleted END LABEL CarryDeleted IF Prep['ok'] <> True THEN SET InputNotice TO $'''⚠ %Prep['message']% (取得は開始していません)''' GOTO ShowInput END SET IdList TO Prep['ids'] SET WordList TO Prep['words'] SET RunFolder TO Prep['runFolder'] GOTO PrepDone LABEL PrepFailed ERROR => PrepError Reset: True SET InputNotice TO $'''⚠ 入力の確認に失敗しました:%PrepError.Message% (取得は開始していません)''' GOTO ShowInput LABEL PrepDone **ENDREGION **REGION 05 ブラウザーとログイン確認 SET LogPath TO $'''%RunFolder%\\%Msg['ログ_全体ファイル名']%''' Text.ConvertDateTimeToText.FromCustomDateTime DateTime: RunStarted CustomFormat: $'''yyyy-MM-dd HH:mm:ss.fff''' Result=> StartedText Variables.ConvertJsonToCustomObject Json: $'''{}''' CustomObject=> WordsObj SET WordsObj['words'] TO WordList Variables.ConvertCustomObjectToJson CustomObject: WordsObj Json=> WordsJson WebAutomation.LaunchEdge.LaunchEdge Url: Site['BaseUrl'] WindowState: WebAutomation.BrowserWindowState.Normal ClearCache: False ClearCookies: False WaitForPageToLoadTimeout: 60 Timeout: 60 BrowserInstance=> Browser ON ERROR GOTO FatalBrowser END # ログイン判定:検索欄が見えるまで。見えなければ人にログインしてもらう(最大3回) LOOP LoginTry FROM 1 TO 5 STEP 1 WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { const s = %SiteJson%; const url = (s.SearchUrlTemplate || "").length > 0; const el = document.querySelector(s.SearchInputSelector); return (url || (el && el.offsetParent !== null)) ? "ok" : "login"; }''' Result=> LoginState ON ERROR GOTO LoginUnknown END GOTO LoginChecked LABEL LoginUnknown SET LoginState TO $'''login''' LABEL LoginChecked IF LoginState = $'''ok''' THEN EXIT LOOP END Display.ShowMessageDialog.ShowMessage Title: Msg['画面_ログインタイトル'] Message: Msg['画面_ログイン本文'] Icon: Display.Icon.Information Buttons: Display.Buttons.OKCancel DefaultButton: Display.DefaultButton.Button1 IsTopMost: True ButtonPressed=> LoginButton IF LoginButton <> $'''OK''' THEN GOTO FatalBrowser END END IF LoginState <> $'''ok''' THEN GOTO FatalBrowser END **ENDREGION **REGION 06 1周目:ID ごとに検索 → PDF 一覧 → 取得を予約(受信は検索トップのタブで裏で進む) SET OkCount TO 0 SET NgCount TO 0 SET NoHitCount TO 0 SET PageNgCount TO 0 SET IdBlocks TO $'''''' # 予約台帳と受信の仕組みを検索トップのタブに置く(同じドメインなので ID ページの PDF も取れる) WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { const s = %SiteJson%; if (!["fetch", "click"].includes(s.DownloadMethod)) throw new Error("DownloadMethodはfetchまたはclick"); window.__padQ = {}; window.__padOrder = []; window.__padActive = 0; function buffered() { let n = 0; for (const id of window.__padOrder) for (const j of window.__padQ[id].jobs) if (j.state === "done") n += j.b64.length; return n; } function finish(q) { q.done = q.jobs.every(j => j.state !== "queued" && j.state !== "receiving"); } function run(q, j) { j.state = "receiving"; window.__padActive++; const ctl = new AbortController(); const timer = setTimeout(function(){ j.state = "failed"; j.reason = "受信の待機上限"; ctl.abort(); }, s.FetchTimeoutSeconds * 1000); fetch(j.url, { credentials: "include", cache: "no-store", signal: ctl.signal }).then(function(res){ if (!res.ok) throw new Error("HTTP " + res.status); const ct = (res.headers.get("content-type") || "").toLowerCase(); if (ct.includes("text/html")) throw new Error("PDF ではなく HTML が返りました(ログイン切れの可能性)"); return res.arrayBuffer(); }).then(async function(buf){ if (j.state !== "receiving") throw new Error(j.reason || "受信取消"); if (buf.byteLength > s.MaxPdfMegabytes * 1048576) throw new Error("サイズ上限超過 " + buf.byteLength + " bytes"); const bytes = new Uint8Array(buf); if (!(bytes[0] === 37 && bytes[1] === 80 && bytes[2] === 68 && bytes[3] === 70)) throw new Error("PDF 署名がありません"); let bin = ""; const chunk = 32768; for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); const hash = await crypto.subtle.digest("SHA-256", bytes); if (j.state !== "receiving") throw new Error("受信取消"); j.receiveHash = Array.from(new Uint8Array(hash), x => x.toString(16).padStart(2,"0")).join(""); j.b64 = btoa(bin); j.bytes = bytes.length; j.state = "done"; }).catch(function(e){ j.state = "failed"; j.reason = j.reason || e.message; }).finally(function(){ clearTimeout(timer); window.__padActive--; finish(q); window.__padPump(); }); } window.__padNext = 0; window.__padTimer = null; window.__padPump = function() { if (s.DownloadMethod === "click") return; if (window.__padTimer) return; const now = Date.now(); if (now < window.__padNext) { window.__padTimer = setTimeout(function(){ window.__padTimer = null; window.__padPump(); }, window.__padNext - now); return; } for (const id of window.__padOrder) { const q = window.__padQ[id]; for (const j of q.jobs) { if (window.__padActive >= s.MaxParallelFetch) return; if (buffered() > s.MaxBufferMegabytes * 1048576) return; if (j.state === "queued") { run(q, j); window.__padNext = Date.now() + s.FetchStartGapMs; window.__padTimer = setTimeout(function(){ window.__padTimer = null; window.__padPump(); }, s.FetchStartGapMs); return; } } } }; window.__padReg = function(id, plan, head) { if (Object.prototype.hasOwnProperty.call(window.__padQ,id)) throw new Error("ID予約重複"); window.__padQ[id] = { id: id, head: head, miss: plan.miss, registeredAt: Date.now(), jobs: plan.jobs.map(function(j){ return { key:j.key, url: j.url, name: j.name, word: j.word, state: s.DownloadMethod === "click" ? "done" : "queued", b64: "", bytes: 0, reason: "", saved: null }; }), done: s.DownloadMethod === "click" || plan.jobs.length === 0, log: null }; window.__padOrder.push(id); window.__padPump(); return "registered"; }; window.__padFixed = function(id, log) { if (Object.prototype.hasOwnProperty.call(window.__padQ,id)) throw new Error("受付済み予約は上書きできません"); window.__padQ[id] = { id: id, jobs: [], miss: [], done: true, log: log }; window.__padOrder.push(id); return "fixed"; }; return "ready"; }''' Result=> QueueState ON ERROR GOTO FatalBrowser END LOOP FOREACH IdNumber IN IdList SET TabOpen TO False SET IdReq TO $'''%RunStamp%_%IdNumber%''' SET ListingState TO $'''failed''' Text.Replace.ReplaceText Text: Msg['ログ_ID見出し'] TextToFind: $'''{ID}''' IgnoreCase: False ReplaceWith: IdNumber ActivateEscapeSequences: False ComparisonType: Text.TextComparisonType.Ordinal Result=> IdHead Text.Replace.ReplaceText Text: IdHead TextToFind: $'''{日時}''' IgnoreCase: False ReplaceWith: StartedText ActivateEscapeSequences: False ComparisonType: Text.TextComparisonType.Ordinal Result=> IdHead Text.Replace.ReplaceText Text: IdHead TextToFind: $'''{ユーザー}''' IgnoreCase: False ReplaceWith: RunUser ActivateEscapeSequences: False ComparisonType: Text.TextComparisonType.Ordinal Result=> IdHead # 新しいタブで検索トップを開く → 検索欄に ID → ボタン → ID のページへ遷移 WebAutomation.CreateNewTab.CreateNewTab BrowserInstance: Browser Url: Site['BaseUrl'] WaitForPageToLoadTimeout: 60 NewBrowserInstance=> Tab ON ERROR GOTO IdSearchFailed END SET TabOpen TO True # 検索前の状態(文書トークン・リンク署名)を記録してから検索する WebAutomation.ExecuteJavascript BrowserInstance: Tab Javascript: $'''function ExecuteScript() { const s = %SiteJson%; const must = new RegExp(s.LinkMustMatch, "i"); const area = document.querySelector(s.ResultAreaSelector) || document.body; const links = Array.from(area.querySelectorAll("a[href]")).filter(a => must.test(a.href) || must.test(a.textContent.trim())); const sig = JSON.stringify(links.map(a => [a.textContent.trim(), a.href])); const ctx = { token: String(performance.timeOrigin), sig: sig, started: Date.now() }; const input = document.querySelector(s.SearchInputSelector); if (!input) throw new Error("search input not found"); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); if (setter && setter.set) setter.set.call(input, "%IdNumber%"); else input.value = "%IdNumber%"; input.dispatchEvent(new Event("input", {bubbles:true})); input.dispatchEvent(new Event("change", {bubbles:true})); const button = (s.SearchButtonSelector || "").length > 0 ? document.querySelector(s.SearchButtonSelector) : null; setTimeout(function(){ if (button) button.click(); else { const form = input.form; if (form && form.requestSubmit) form.requestSubmit(); else input.dispatchEvent(new KeyboardEvent("keydown", {key:"Enter", code:"Enter", keyCode:13, bubbles:true})); } }, 100); return JSON.stringify(ctx); }''' Result=> CtxJson ON ERROR GOTO IdSearchFailed END Variables.ConvertJsonToCustomObject Json: CtxJson CustomObject=> Ctx # 結果待ち:PDF リンク一覧が StableSeconds 変わらず、検索前と違う(または文書が変わった)とき確定 SET ListingState TO $'''waiting''' LOOP PollIndex FROM 1 TO 1000 STEP 1 WebAutomation.ExecuteJavascript BrowserInstance: Tab Javascript: $'''function ExecuteScript() { const s = %SiteJson%; const req = "%IdReq%"; const must = new RegExp(s.LinkMustMatch, "i"); const area = document.querySelector(s.ResultAreaSelector) || document.body; const links = Array.from(area.querySelectorAll("a[href]")).filter(a => must.test(a.href) || must.test(a.textContent.trim())).map(a => ({ text: a.textContent.trim(), url: a.href })); const sig = JSON.stringify(links.map(l => [l.text, l.url])); const now = Date.now(); if (!window.__padW || window.__padW.req !== req) window.__padW = { req: req, sig: null, since: now, started: Number("%Ctx['started']%") || now }; const w = window.__padW; if (sig !== w.sig) { w.sig = sig; w.since = now; } const changed = String(performance.timeOrigin) !== "%Ctx['token']%" || sig !== JSON.stringify(%Ctx['sig']%); const arrived = (s.ArrivalText || "").length === 0 || document.body.textContent.includes(s.ArrivalText); const stable = (now - w.since) / 1000 >= s.StableSeconds; let state = "waiting"; if ((s.FailedSelector || "").length > 0 && document.querySelector(s.FailedSelector)) { state = "failed"; } else if (changed && arrived && stable) { if (links.length > 0) { state = "ready"; w.links = links; } else if ((s.ArrivalText || "").length > 0) { state = "empty"; } } if (state === "waiting" && (now - w.started) / 1000 >= s.ResultWaitSeconds) state = "timeout"; return JSON.stringify({ state: state, count: links.length }); }''' Result=> WaitJson ON ERROR GOTO PollNext END Variables.ConvertJsonToCustomObject Json: WaitJson CustomObject=> WaitResult IF WaitResult['state'] <> $'''waiting''' THEN SET ListingState TO WaitResult['state'] EXIT LOOP END LABEL PollNext WAIT Site['PollSeconds'] END IF ListingState = $'''waiting''' THEN SET ListingState TO $'''timeout''' END GOTO IdSearchChecked LABEL IdSearchFailed ERROR => IdSearchError Reset: True SET ListingState TO $'''failed''' LABEL IdSearchChecked IF ListingState = $'''ready''' THEN # 検索ワードで絞り込み、保存名を決める(_<リンク文字>.pdf、使えない文字は _ に)→ 検索トップのタブに予約 WebAutomation.ExecuteJavascript BrowserInstance: Tab Javascript: $'''function ExecuteScript() { const words = (%WordsJson%).words; const jobs = []; const used = new Set(); const bad = /[<>:"\\/\\\\|?*\\x00-\\x1f]/g; for (const l of window.__padW.links) { const hit = words.filter(w => l.text.includes(w)); if (!hit.length) continue; let base = l.text.replace(/[.]pdf$/i, "").replace(bad, "_").trim(); if (!base) base = "file"; let name = "%IdNumber%_" + base + ".pdf"; let n = 2; while (used.has(name.toLowerCase())) { name = "%IdNumber%_" + base + "_" + n + ".pdf"; n++; } used.add(name.toLowerCase()); jobs.push({ key: crypto.randomUUID(), url: l.url, text: l.text, word: hit.join(" / "), name: name }); } const miss = words.filter(w => !jobs.some(j => j.word.split(" / ").includes(w))); return JSON.stringify({ jobs: jobs, miss: miss }); }''' Result=> PlanJson ON ERROR GOTO IdPlanFailed END File.WriteText File: $'''%RunFolder%\\%IdNumber%_取得予定.json''' TextToWrite: PlanJson AppendNewLine: False IfFileExists: File.IfFileExists.Overwrite Encoding: File.FileEncoding.UTF8 ON ERROR GOTO FatalBrowser END Variables.ConvertJsonToCustomObject Json: PlanJson CustomObject=> Plan Variables.IncreaseVariable Value: NoHitCount IncrementValue: Plan['miss'].Count Variables.ConvertJsonToCustomObject Json: $'''{}''' CustomObject=> HeadObj SET HeadObj['head'] TO IdHead Variables.ConvertCustomObjectToJson CustomObject: HeadObj Json=> HeadJson WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { return window.__padReg("%IdNumber%", %PlanJson%, (%HeadJson%).head); }''' Result=> RegState ON ERROR GOTO IdPlanFailed END IF RegState <> $'''registered''' THEN GOTO IdPlanFailed END GOTO IdRegistered LABEL IdPlanFailed ERROR => IdPlanError Reset: True SET ListingState TO $'''failed''' END IF ListingState = $'''empty''' THEN Variables.IncreaseVariable Value: NoHitCount IncrementValue: WordList.Count SET FixedLog TO $'''%Msg['ログ_区切り']% %IdHead% %Msg['ログ_検索結果0件']%''' END IF ListingState <> $'''ready''' AND ListingState <> $'''empty''' THEN Variables.IncreaseVariable Value: PageNgCount IncrementValue: 1 SET FixedLog TO $'''%Msg['ログ_区切り']% %IdHead% %Msg['ログ_ページ読取失敗']%''' END IF ListingState <> $'''ready''' THEN File.WriteText File: $'''%RunFolder%\\%IdNumber%_取得予定.json''' TextToWrite: $'''{"jobs":[],"miss":[]}''' AppendNewLine: False IfFileExists: File.IfFileExists.Overwrite Encoding: File.FileEncoding.UTF8 ON ERROR GOTO FatalBrowser END Variables.ConvertJsonToCustomObject Json: $'''{}''' CustomObject=> HeadObj SET HeadObj['log'] TO FixedLog Variables.ConvertCustomObjectToJson CustomObject: HeadObj Json=> HeadJson WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { return window.__padFixed("%IdNumber%", (%HeadJson%).log); }''' Result=> RegState ON ERROR GOTO FatalBrowser END END LABEL IdRegistered # ID結果タブは確認用に残す。受信の基点は検索トップタブ。 END **ENDREGION **REGION 07 2周目:ID 順に 受信確認 → 保存 → ID ログ SET SaveOut TO $'''''' LOOP FOREACH IdNumber IN IdList SET PushMiss TO 0 SET IdLog TO $'''''' # この ID の受信が全部終わるまで待つ(予約した順に裏で進んでいる) LOOP WaitIndex FROM 1 TO 2000 STEP 1 WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { const s = %SiteJson%; const q = window.__padQ["%IdNumber%"]; if (!q) return JSON.stringify({ done: true, n: 0, fixed: true }); if (!q.done && q.registeredAt && (Date.now() - q.registeredAt) / 1000 >= s.CollectWaitSeconds) { for (const j of q.jobs) if (j.state === "queued" || j.state === "receiving") { j.state = "failed"; j.reason = j.reason || "受信の待機上限(" + s.CollectWaitSeconds + "秒)"; } q.done = true; } return JSON.stringify({ done: q.done, n: q.jobs.length, fixed: q.log !== null }); }''' Result=> QJson ON ERROR GOTO QueueLost END Variables.ConvertJsonToCustomObject Json: QJson CustomObject=> QState IF QState['done'] = True THEN EXIT LOOP END WAIT 0.5 END IF QState['done'] <> True THEN # 待機上限:残りは失敗扱いにして進む WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { const q = window.__padQ["%IdNumber%"]; for (const j of q.jobs) if (j.state === "queued" || j.state === "receiving") { j.state = "failed"; j.reason = j.reason || "受信の待機上限"; } q.done = true; return "expired"; }''' Result=> QState2 ON ERROR GOTO QueueLost END END IF QState['fixed'] = True THEN WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { const q = window.__padQ["%IdNumber%"]; return q ? q.log : "(この ID の記録がありません)"; }''' Result=> IdLog ON ERROR GOTO QueueLost END GOTO IdLogReady END SET JobCount TO QState['n'] IF JobCount > 0 THEN SET JobLast TO JobCount - 1 LOOP JobIndex FROM 0 TO JobLast STEP 1 SET SaveState TO $'''failed''' SET SaveReason TO $'''''' WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { const j = window.__padQ["%IdNumber%"].jobs[%JobIndex%]; return JSON.stringify({ state: j.state, reason: j.reason, name: j.name, word: j.word }); }''' Result=> JobJson ON ERROR GOTO SaveFailed END Variables.ConvertJsonToCustomObject Json: JobJson CustomObject=> Job SET FinalName TO Job['name'] IF Job['state'] <> $'''done''' THEN SET SaveReason TO Job['reason'] GOTO SaveRecorded END # base64 を受け取り(受け取ったら台帳から消す)→ 一時ファイル(1行目=保存名)→ PowerShell で検証して保存 WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { function takeDownload(j, s, scope = window) { if (s.DownloadMethod === "fetch") { if (j.state !== "done") throw new Error("受信が完了していません"); const data = j.b64; j.b64 = ""; j.state = "taken"; scope.__padPump(); return JSON.stringify({kind:"fetch", key:j.key, url:j.url, expectedName:j.name, bytes:j.bytes, sha256:j.receiveHash, b64:data}); } if (s.DownloadMethod !== "click") throw new Error("DownloadMethodはfetchまたはclick"); if (!s.BrowserDownloadFolder) throw new Error("click方式にはBrowserDownloadFolderの設定が必要"); if (j.clickRequested) throw new Error("同じ予約への再クリックは行いません"); const url = new URL(j.url, scope.location.href); if (!["http:", "https:"].includes(url.protocol)) throw new Error("HTTP(S)リンク以外は未対応"); const filename = "PAD_CLICK_" + scope.crypto.randomUUID() + ".pdf"; const a = scope.document.createElement("a"); a.href = url.href; a.download = filename; a.target = "_blank"; a.rel = "noopener"; const requestedAt = new Date().toISOString(); scope.document.body.append(a); j.clickRequested = true; try { a.click(); } finally { a.remove(); } j.state = "taken"; return JSON.stringify({kind:"click", key:j.key, url:j.url, expectedName:j.name, filename, folder:s.BrowserDownloadFolder, seconds:s.FetchTimeoutSeconds, maxMB:s.MaxPdfMegabytes, requestedAt}); } return takeDownload(window.__padQ["%IdNumber%"].jobs[%JobIndex%], %SiteJson%); }''' Result=> Base64Text ON ERROR GOTO SaveFailed END File.GetTempPath TempFile=> B64File File.WriteText File: B64File TextToWrite: $'''%Job['name']% %RunFolder% %Base64Text%''' AppendNewLine: False IfFileExists: File.IfFileExists.Overwrite Encoding: File.FileEncoding.UTF8 SET Base64Text TO $'''''' Scripting.RunPowershellScript.RunScript Script: $'''$ErrorActionPreference = "Stop" $src = "%B64File%" function HashFile([string]$path) { $h=[Security.Cryptography.SHA256]::Create(); $f=[IO.File]::OpenRead($path); try { return ([BitConverter]::ToString($h.ComputeHash($f))).Replace("-", "").ToLowerInvariant() } finally { $f.Dispose(); $h.Dispose() } } try { $parts = [IO.File]::ReadAllText($src, [Text.Encoding]::UTF8).Split(@([char]10), 3) if ($parts.Count -ne 3) { throw "一時ファイルの形式が不正" } $base = $parts[0].TrimEnd([char]13); $dir = $parts[1].TrimEnd([char]13); $payload = $parts[2].Trim() $p = $payload | ConvertFrom-Json if (-not $p.key -or $p.expectedName -cne $base -or $p.kind -notin @("fetch","click")) { throw "予約情報不一致" } if ([IO.Path]::GetFileName($base) -ne $base) { throw "保存名が不正" } if (-not [IO.Directory]::Exists($dir)) { throw "保存先がありません" } if ($p.kind -eq "click") { $p = $payload | ConvertFrom-Json if ($p.kind -ne "click" -or $p.filename -notmatch "^PAD_CLICK_[a-fA-F0-9-]{36}[.]pdf$") { throw "クリック予約情報が不正" } if (-not [IO.Path]::IsPathRooted($p.folder) -or -not [IO.Directory]::Exists($p.folder)) { throw "ブラウザー保存先が見つかりません" } $seconds = [double]$p.seconds; $maxBytes = [double]$p.maxMB * 1048576 if ($seconds -le 0 -or $seconds -gt 3600 -or $maxBytes -le 0) { throw "待機・容量設定が不正" } $candidate = [IO.Path]::Combine($p.folder, $p.filename) $deadline = [DateTime]::UtcNow.AddSeconds($seconds); $lastSize = -1; $stable = 0; $bytes = $null while ([DateTime]::UtcNow -lt $deadline) { if ([IO.File]::Exists($candidate) -and -not [IO.File]::Exists($candidate + ".crdownload")) { $item = Get-Item -LiteralPath $candidate -Force if ($item.LinkType -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "リンクファイルは対象外" } if ($item.CreationTimeUtc -lt ([DateTime]::Parse($p.requestedAt).ToUniversalTime().AddSeconds(-2))) { throw "予約前のファイルは対象外" } if ($item.Length -gt $maxBytes) { throw "サイズ上限超過" } if ($item.Length -gt 0 -and $item.Length -eq $lastSize) { $stable++ } else { $stable = 0 } $lastSize = $item.Length if ($stable -ge 2) { $stream = $null try { $stream = [IO.File]::Open($candidate, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) if ($stream.Length -ne $lastSize) { throw "受信中" } $memory = [IO.MemoryStream]::new(); $stream.CopyTo($memory); $bytes = $memory.ToArray(); $memory.Dispose() } catch { $stable = 0 } finally { if ($stream) { $stream.Dispose() } } if ($null -ne $bytes) { break } } } else { $stable = 0; $lastSize = -1 } Start-Sleep -Milliseconds 500 } if ($null -eq $bytes) { throw "保存未確認。PDF表示・保存確認画面・download名の無視・保存先設定を確認してください(自動再クリックなし)" } } else { $bytes = [Convert]::FromBase64String($p.b64) } if ($bytes.Length -lt 100) { throw "サイズが小さすぎます" } if ([Text.Encoding]::ASCII.GetString($bytes, 0, 5) -ne ([string][char]37 + "PDF-")) { throw "PDFではありません" } $tailStart = [Math]::Max(0, $bytes.Length - 2048) $tail = [Text.Encoding]::ASCII.GetString($bytes, $tailStart, $bytes.Length - $tailStart) if ($tail -notmatch ([string][char]37 + [char]37 + "EOF\\s*$")) { throw "PDF終端を確認できません" } $sha = [Security.Cryptography.SHA256]::Create() try { $hash = ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace("-", "").ToLowerInvariant() } finally { $sha.Dispose() } if ($p.kind -eq "fetch" -and ([long]$p.bytes -ne $bytes.Length -or $p.sha256 -cne $hash)) { throw "受信データと保存入力が一致しません" } $name = [IO.Path]::GetFileNameWithoutExtension($base) + "(DL=" + (Get-Date -Format "yyyyMMdd_HHmmss") + ").pdf" $path = [IO.Path]::Combine($dir, $name) $output = [IO.File]::Open($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { $output.Write($bytes, 0, $bytes.Length); $output.Flush() } finally { $output.Dispose() } if ((Get-Item -LiteralPath $path).Length -ne $bytes.Length) { throw "保存後のサイズが一致しません" } if ((HashFile $path) -cne $hash) { throw "保存後の内容が一致しません" } @{ok=$true;key=$p.key;url=$p.url;expectedName=$base;name=$name;bytes=$bytes.Length;sha256=$hash;reason=""} | ConvertTo-Json -Compress } catch { @{ok=$false;reason=$_.Exception.Message} | ConvertTo-Json -Compress }''' ScriptOutput=> SaveOut ScriptError=> SaveErr ON ERROR GOTO SaveFailed END File.Delete Files: B64File ON ERROR GOTO B64Deleted END LABEL B64Deleted Variables.ConvertJsonToCustomObject Json: SaveOut CustomObject=> SaveReceipt ON ERROR GOTO SaveFailed END IF SaveReceipt['ok'] = True THEN SET SaveState TO $'''ok''' SET FinalName TO SaveReceipt['name'] ELSE SET SaveReason TO SaveReceipt['reason'] END GOTO SaveRecorded LABEL SaveFailed ERROR => SaveError Reset: True SET SaveReason TO SaveError.Message LABEL SaveRecorded IF SaveState = $'''ok''' THEN Variables.IncreaseVariable Value: OkCount IncrementValue: 1 ELSE Variables.IncreaseVariable Value: NgCount IncrementValue: 1 END Variables.ConvertJsonToCustomObject Json: $'''{}''' CustomObject=> PushObj SET PushObj['ok'] TO SaveState SET PushObj['text'] TO FinalName SET PushObj['reason'] TO SaveReason SET PushObj['receipt'] TO SaveOut Variables.ConvertCustomObjectToJson CustomObject: PushObj Json=> PushJson WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { const p=%PushJson%; const j=window.__padQ["%IdNumber%"].jobs[%JobIndex%]; if(p.ok === "ok") { const r=JSON.parse(p.receipt); if(!r.ok || r.key !== j.key || r.url !== j.url || r.expectedName !== j.name || r.name !== p.text || ((%SiteJson%).DownloadMethod === "fetch" && (r.bytes !== j.bytes || r.sha256 !== j.receiveHash))) throw new Error("保存証跡不一致"); j.receipt=r; } j.saved={ok:p.ok === "ok",text:p.text,reason:p.reason}; return "pushed"; }''' Result=> PushState ON ERROR GOTO PushFailed END GOTO ResultPushed LABEL PushFailed ERROR => PushError Reset: True Variables.IncreaseVariable Value: PushMiss IncrementValue: 1 LABEL ResultPushed END END # ID ログ本文を整形(検索ワードごとに見出し 1 回。該当なしワードは最後) WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { const M = %MsgJson%; const fill = (t, m) => Object.keys(m).reduce((a, k) => a.split("{" + k + "}").join(m[k]), t); const q = window.__padQ["%IdNumber%"]; const groups = []; const idx = {}; for (const j of q.jobs) { const r = j.saved || { ok: false, text: j.name, reason: "結果が記録されていません" }; if (idx[j.word] === undefined) { idx[j.word] = groups.length; groups.push({ word: j.word, lines: [] }); } groups[idx[j.word]].lines.push(r.ok ? fill(M["ログ_成功"], {名前: r.text}) : fill(M["ログ_失敗"], {名前: r.text, 理由: r.reason})); } const out = [M["ログ_区切り"], q.head]; if (%PushMiss% > 0) out.push(fill(M["ログ_記録失敗注記"], {件数: String(%PushMiss%)})); for (const g of groups) { out.push(fill(M["ログ_ワード見出し"], {ワード: g.word})); out.push(...g.lines); } for (const w of q.miss) { out.push(fill(M["ログ_ワード見出し"], {ワード: w})); out.push(M["ログ_一致なし"]); } return out.join(String.fromCharCode(10)); }''' Result=> IdLog ON ERROR GOTO QueueLost END GOTO IdLogReady LABEL QueueLost ERROR => QueueError Reset: True Text.Replace.ReplaceText Text: Msg['ログ_ID見出し'] TextToFind: $'''{ID}''' IgnoreCase: False ReplaceWith: IdNumber ActivateEscapeSequences: False ComparisonType: Text.TextComparisonType.Ordinal Result=> IdHead SET IdLog TO $'''%Msg['ログ_区切り']% %IdHead% (受信台帳を読めませんでした:%QueueError.Message%。全体ログの件数と保存先の実ファイルを確認してください)''' LABEL IdLogReady SET IdBlocks TO $'''%IdBlocks%%IdLog% ''' # ID 別ログ(結果画面・全体ログと同じ文字列。振り分けフローが使う) File.WriteText File: $'''%RunFolder%\\%IdNumber%_ダウンロード結果_%RunStamp%.txt''' TextToWrite: IdLog AppendNewLine: True IfFileExists: File.IfFileExists.Overwrite Encoding: File.FileEncoding.UTF8 ON ERROR GOTO IdLogWritten END LABEL IdLogWritten END **ENDREGION **REGION 08 最終照合と全体ログ DateTime.GetCurrentDateTime.Local DateTimeFormat: DateTime.DateTimeFormat.DateAndTime CurrentDateTime=> RunEnded Text.ConvertDateTimeToText.FromCustomDateTime DateTime: RunEnded CustomFormat: $'''yyyy-MM-dd HH:mm:ss.fff''' Result=> EndedText # 予約台帳の「成功」全件について、保存先に同名ファイルがあり受信サイズと一致するか。予定に無い PDF が無いか WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { return JSON.stringify({ids:(%PrepJson%).ids, stamp:"%RunStamp%",method:(%SiteJson%).DownloadMethod,ok:%OkCount%,ng:%NgCount%,rows:window.__padOrder.map(id=>{ const q=window.__padQ[id]; return {id,jobs:q.jobs.map(j=>({key:j.key,url:j.url,name:j.name,word:j.word,bytes:j.bytes,receiveHash:j.receiveHash,saved:j.saved,receipt:j.receipt}))}; })}); }''' Result=> ExpectJson ON ERROR GOTO ReconcileUnknown END File.GetTempPath TempFile=> ExpectFile File.WriteText File: ExpectFile TextToWrite: ExpectJson AppendNewLine: False IfFileExists: File.IfFileExists.Overwrite Encoding: File.FileEncoding.UTF8 Scripting.RunPowershellScript.RunScript Script: $'''$ErrorActionPreference = "Stop" $src = "%ExpectFile%" $dir = "%RunFolder%" function HashFile([string]$path) { $h = [Security.Cryptography.SHA256]::Create(); $f = [IO.File]::OpenRead($path) try { return ([BitConverter]::ToString($h.ComputeHash($f))).Replace("-", "").ToLowerInvariant() } finally { $f.Dispose(); $h.Dispose() } } try { $run = [IO.File]::ReadAllText($src, [Text.Encoding]::UTF8) | ConvertFrom-Json $problems = [Collections.Generic.List[string]]::new() $ids = @{}; $rows = @{}; $keys = @{}; $names = @{}; $ok = 0; $ng = 0 foreach ($id in $run.ids) { if ($ids.ContainsKey([string]$id)) { throw "ID重複" }; $ids[[string]$id] = 1 } foreach ($row in $run.rows) { $id = [string]$row.id if (-not $ids.ContainsKey($id) -or $rows.ContainsKey($id)) { throw "予約IDの不一致・重複" } $rows[$id] = 1 $plan = [IO.File]::ReadAllText([IO.Path]::Combine($dir, $id + "_取得予定.json"), [Text.Encoding]::UTF8) | ConvertFrom-Json if (@($plan.jobs).Count -ne @($row.jobs).Count) { throw "予約件数の変化:$id" } for ($i=0; $i -lt @($plan.jobs).Count; $i++) { $p = $plan.jobs[$i]; $j = $row.jobs[$i] foreach ($field in @("key","url","name","word")) { if ([string]$p.$field -cne [string]$j.$field) { throw "予約内容が変化:$id / $field" } } if (-not $p.key -or $keys.ContainsKey([string]$p.key)) { throw "予約キー不正・重複" }; $keys[[string]$p.key] = 1 if ($null -eq $j.saved) { $problems.Add("結果未記録:" + $p.name); continue } if (-not $j.saved.ok) { $ng++; continue } $ok++; $r = $j.receipt if ($null -eq $r -or -not $r.ok -or $r.key -cne $p.key -or $r.url -cne $p.url -or $r.expectedName -cne $p.name -or $r.name -cne $j.saved.text -or $r.sha256 -notmatch "^[a-f0-9]{64}$") { throw "保存証跡不一致:$id" } if ($run.method -eq "fetch" -and ($r.sha256 -cne $j.receiveHash -or [long]$r.bytes -ne [long]$j.bytes)) { throw "受信と保存の不一致:$id" } $name = [string]$r.name if ([IO.Path]::GetFileName($name) -cne $name -or $names.ContainsKey($name)) { throw "保存名不正・重複" }; $names[$name] = 1 $path = [IO.Path]::Combine($dir,$name) if (-not [IO.File]::Exists($path)) { $problems.Add("無い:"+$name); continue } $file = Get-Item -LiteralPath $path -Force if ($file.LinkType) { $problems.Add("リンク:"+$name); continue } if ($file.Length -ne [long]$r.bytes) { $problems.Add("サイズ違い:"+$name); continue } if ((HashFile $path) -cne $r.sha256) { $problems.Add("内容違い:"+$name) } } if (-not [IO.File]::Exists([IO.Path]::Combine($dir, $id + "_ダウンロード結果_" + $run.stamp + ".txt"))) { $problems.Add("IDログ未保存:"+$id) } } if ($rows.Count -ne $ids.Count) { throw "未処理IDがあります" } foreach ($f in (Get-ChildItem -LiteralPath $dir -File -Filter *.pdf)) { if (-not $names.ContainsKey($f.Name)) { $problems.Add("予定外:"+$f.Name) } } if ($ok -ne [int]$run.ok -or $ng -ne [int]$run.ng -or $keys.Count -ne ($ok+$ng)) { $problems.Add("予定・結果・集計の件数不一致") } if ($problems.Count) { "不一致:" + ($problems -join " / ") } else { "一致(予定"+$keys.Count+"件の結果確定/保存"+$ok+"件の名前・サイズ・SHA-256一致/予定外なし。サイト一覧の完全性・文書の業務上の正しさは別途確認)" } } catch { "未確認(照合に失敗:"+$_.Exception.Message+")" }''' ScriptOutput=> ReconcileOut ScriptError=> ReconcileErr ON ERROR GOTO ReconcileUnknown END File.WriteText File: $'''%RunFolder%\\照合証跡.json''' TextToWrite: ExpectJson AppendNewLine: False IfFileExists: File.IfFileExists.Overwrite Encoding: File.FileEncoding.UTF8 ON ERROR GOTO ReconcileUnknown END LABEL ExpectDeleted SET Reconcile TO ReconcileOut.Trimmed GOTO Reconciled LABEL ReconcileUnknown ERROR => ReconcileError Reset: True SET Reconcile TO $'''未確認(保存先の一覧を取得できません)''' LABEL Reconciled SET Total TO OkCount + NgCount Text.ConvertDateTimeToText.FromCustomDateTime DateTime: RunStarted CustomFormat: $'''yyyy-MM-dd HH:mm:ss''' Result=> StartedShort Text.ConvertDateTimeToText.FromCustomDateTime DateTime: RunEnded CustomFormat: $'''HH:mm:ss''' Result=> EndedShort SET Summary TO $'''成功 %OkCount% / 失敗 %NgCount%(対象 %Total%) ページ読取失敗 %PageNgCount% ID 該当なし %NoHitCount%''' File.WriteText File: LogPath TextToWrite: $'''%Msg['ログ_全体見出し']% 件数 :%Summary% 実行 :%StartedShort% ~ %EndedShort% %RunUser% 保存先:%RunFolder% 照合 :%Reconcile% %Msg['ログ_全体注記']% %IdBlocks%''' AppendNewLine: False IfFileExists: File.IfFileExists.Overwrite Encoding: File.FileEncoding.UTF8 ON ERROR GOTO LogWriteFailed END GOTO LogWritten LABEL LogWriteFailed ERROR => LogWriteError Reset: True SET Summary TO $'''%Summary% ⚠ 全体ログの保存に失敗(ID 別ログは保存先を確認)''' LABEL LogWritten File.ReadTextFromFile.ReadText File: LogPath Encoding: File.TextFileEncoding.UTF8 Content=> LogText ON ERROR GOTO LogUnread END GOTO LogRead LABEL LogUnread SET LogText TO $'''(ログを読めませんでした:%LogPath%)''' LABEL LogRead LABEL ShowResult Display.ShowCustomDialog CardTemplateJson: '''{ "type": "AdaptiveCard", "version": "1.4", "id": "AdaptiveCard", "body": [ { "type": "TextBlock", "id": "Summary", "text": "${Summary_Text}", "wrap": true, "weight": "Bolder" }, { "type": "TextBlock", "id": "Where", "text": "${Folder_Text}", "wrap": true }, { "type": "Input.Text", "id": "Log", "label": "${Log_Label}", "isMultiline": true, "value": "${Log_Value}" }, { "type": "TextBlock", "id": "Guide", "text": "${Guide_Text}", "wrap": true, "size": "Small", "isSubtle": true } ], "actions": [ { "type": "Action.Submit", "id": "Open", "title": "${Open_Title}" }, { "type": "Action.Submit", "id": "Close", "title": "${Close_Title}", "associatedInputs": "none" } ], "FormTitle": "${Form_Title}" }''' CustomFormData=> ResultData ButtonPressed=> ResultButton @Summary_Text: Summary @Folder_Text: RunFolder @Log_Value: LogText @Log_Label: Msg['画面_結果ログ欄'] @Guide_Text: Msg['画面_結果案内'] @Open_Title: Msg['画面_保存先を開く'] @Close_Title: Msg['画面_閉じる'] @Form_Title: Msg['画面_結果タイトル'] ON ERROR GOTO Quit END IF ResultButton = $'''Open''' THEN System.RunApplication.RunApplication WindowStyle: System.ProcessWindowStyle.Normal WorkingDirectory: $'''%RunFolder%''' ApplicationPath: $'''explorer.exe''' CommandLineArguments: $'''"%RunFolder%"''' ProcessId=> ExplorerPid ON ERROR GOTO Opened END LABEL Opened GOTO ShowResult END EXIT Code: 0 LABEL FatalBrowser ERROR => BrowserError Reset: True File.WriteText File: LogPath TextToWrite: $'''[中断] %Msg['画面_中断本文']% 実行ユーザー:%RunUser% 開始:%StartedText%''' AppendNewLine: True IfFileExists: File.IfFileExists.Append Encoding: File.FileEncoding.UTF8 ON ERROR GOTO FatalLogged END LABEL FatalLogged Display.ShowMessageDialog.ShowMessage Title: Msg['画面_中断タイトル'] Message: $'''%Msg['画面_中断本文']% %LogPath%''' Icon: Display.Icon.Warning Buttons: Display.Buttons.OK DefaultButton: Display.DefaultButton.Button1 IsTopMost: True ButtonPressed=> FatalButton EXIT Code: 0 LABEL Quit EXIT Code: 0 **ENDREGION