**REGION 00 サイト設定(会社サイトに合わせて直すのはここだけ) # BaseUrl : 検索画面の URL # SearchInputSelector : ID を入れる検索欄の CSS セレクター(F12 → 要素を右クリック → Copy selector) # SearchButtonSelector : 検索ボタンの CSS セレクター。空なら検索欄で Enter Variables.ConvertJsonToCustomObject Json: $'''{ "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件)", "画面_保存先欄": "保存先フォルダー(どこでも可。Edge のダウンロード設定とは無関係。この中に 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['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%; 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.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(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)); if (j.state !== "receiving") throw new Error(j.reason || "受信取消"); j.b64 = btoa(bin); j.bytes = bytes.length; j.state = "done"; }).catch(function(e){ if (j.state === "receiving" || j.state === "done") { 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 (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) { window.__padQ[id] = { id: id, head: head, miss: plan.miss, registeredAt: Date.now(), jobs: plan.jobs.map(function(j){ return { url: j.url, name: j.name, word: j.word, state: "queued", b64: "", bytes: 0, reason: "", saved: null }; }), done: plan.jobs.length === 0, log: null }; window.__padOrder.push(id); window.__padPump(); return "registered"; }; window.__padFixed = function(id, log) { 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({ 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 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 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 IF TabOpen = True THEN WebAutomation.CloseWebBrowser BrowserInstance: Tab ON ERROR GOTO TabClosed END LABEL TabClosed SET TabOpen TO False END END **ENDREGION **REGION 07 2周目:ID 順に 受信確認 → 保存 → ID ログ 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() { const j = window.__padQ["%IdNumber%"].jobs[%JobIndex%]; if (j.state !== "done") throw new Error("受信が完了していません"); const b = j.b64; j.b64 = ""; j.state = "taken"; window.__padPump(); return b; }''' Result=> Base64Text ON ERROR GOTO SaveFailed END File.GetTempPath TempFile=> B64File File.WriteText File: B64File TextToWrite: $'''%Job['name']% %Base64Text%''' AppendNewLine: False IfFileExists: File.IfFileExists.Overwrite Encoding: File.FileEncoding.UTF8 SET Base64Text TO $'''''' Scripting.RunPowershellScript.RunScript Script: $'''$ErrorActionPreference = "Stop" $src = "%B64File%"; $dir = "%RunFolder%" try { $all = [IO.File]::ReadAllText($src, [Text.Encoding]::UTF8); $nl = $all.IndexOf([char]10) if ($nl -lt 1) { throw "一時ファイルの形式が不正" } $base = $all.Substring(0, $nl).Trim(); $bytes = [Convert]::FromBase64String($all.Substring($nl + 1).Trim()) if ($bytes.Length -lt 100) { throw "サイズが小さすぎます" } $head = [Text.Encoding]::ASCII.GetString($bytes, 0, 5); if ($head -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 の終端(EOF)を確認できません" } $name = [IO.Path]::GetFileNameWithoutExtension($base) + "(DL=" + (Get-Date -Format "yyyyMMdd_HHmmss") + ").pdf" $path = [IO.Path]::Combine($dir, $name) if ([IO.File]::Exists($path)) { throw "同名ファイルがあるため上書きしません" } $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 "保存後のサイズが一致しません" } "ok " + $bytes.Length + " " + $name } catch { "ng " + $_.Exception.Message }''' ScriptOutput=> SaveOut ScriptError=> SaveErr ON ERROR GOTO SaveFailed END File.Delete Files: B64File ON ERROR GOTO B64Deleted END LABEL B64Deleted Text.ParseText.RegexParse Text: SaveOut.Trimmed TextToFind: $'''(?<=^ok \\d+ ).+$''' StartingPosition: 0 IgnoreCase: False Matches=> OkMatch IF OkMatch.Count = 1 THEN SET SaveState TO $'''ok''' SET FinalName TO OkMatch[0] ELSE SET SaveReason TO SaveOut.Trimmed 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.Trimmed Variables.ConvertCustomObjectToJson CustomObject: PushObj Json=> PushJson WebAutomation.ExecuteJavascript BrowserInstance: Browser Javascript: $'''function ExecuteScript() { const p = %PushJson%; const j = window.__padQ["%IdNumber%"].jobs[%JobIndex%]; let saved = 0; if (p.ok === "ok") { const m = /^ok ([0-9]+) /.exec(String(p.receipt)); if (!m) throw new Error("保存結果の形式が不正"); saved = Number(m[1]); if (j.bytes && saved !== j.bytes) throw new Error("保存サイズが受信サイズと一致しません"); } j.saved = { ok: p.ok === "ok", bytes: saved, text: String(p.text).replace(/[\r\n]+/g, "").trim(), reason: String(p.reason).replace(/[\r\n]+/g, " ").trim() }; 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() { const list = []; for (const id of window.__padOrder) for (const j of window.__padQ[id].jobs) if (j.saved && j.saved.ok) list.push({ name: j.saved.text, bytes: j.saved.bytes || j.bytes }); return JSON.stringify(list); }''' 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" $dir = "%RunFolder%"; $src = "%ExpectFile%" try { $exp = ConvertFrom-Json ([IO.File]::ReadAllText($src, [Text.Encoding]::UTF8)); if ($null -eq $exp) { $exp = @() } elseif ($exp -isnot [array]) { $exp = @($exp) } $files = @{}; foreach ($f in (Get-ChildItem -LiteralPath $dir -File -Filter *.pdf)) { $files[$f.Name] = $f.Length } $missing = @(); $sizeNg = @(); $seen = @{} foreach ($e in $exp) { $n = ([string]$e.name).Trim(); $seen[$n] = 1; if (-not $files.ContainsKey($n)) { $missing += $n } elseif ($files[$n] -ne [long]$e.bytes) { $sizeNg += $n } } $extra = @($files.Keys | Where-Object { -not $seen.ContainsKey($_) }) if ($missing.Count -eq 0 -and $sizeNg.Count -eq 0 -and $extra.Count -eq 0) { "一致(成功" + $exp.Count + "件すべて保存先に存在・サイズ一致・予定外なし)" } else { $out = @("不一致(無い" + $missing.Count + "件/サイズ違い" + $sizeNg.Count + "件/予定外" + $extra.Count + "件)"); foreach ($n in $missing) { $out += " 無い  :$n" }; foreach ($n in $sizeNg) { $out += " サイズ違い:$n" }; foreach ($n in $extra) { $out += " 予定外 :$n" }; $out -join "`r`n" } } catch { "未確認(照合に失敗:" + $_.Exception.Message + ")" }''' ScriptOutput=> ReconcileOut ScriptError=> ReconcileErr ON ERROR GOTO ReconcileUnknown END File.Delete Files: ExpectFile ON ERROR GOTO ExpectDeleted 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