|
#!/usr/bin/env bun |
|
|
|
import * as cheerio from 'cheerio'; |
|
|
|
const [url] = process.argv.slice(2); |
|
if (!url) { |
|
throw new Error('첨부파일이 있는 상세 페이지 URL을 입력하세요.'); |
|
} |
|
|
|
const html = await fetch(new URL(url), { |
|
method: 'GET', |
|
headers: { |
|
'Accept': 'text/html', |
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', |
|
}, |
|
}).then(res => res.text()); |
|
|
|
const $ = cheerio.load(html); |
|
const filelistSource = $('#dext5-container script').text(); |
|
const filelistPattern = /DEXT5UPLOAD\.AddUploadedFile\(\s*'(?<filenum>\d+)',\s*'(?<webName>[^']+)',\s*'(?<origName>[^']+)'/g; |
|
|
|
type FileInfo = { |
|
filenum: string, |
|
webName: string, |
|
origName: string, |
|
}; |
|
|
|
const filelist = [...filelistSource.matchAll(filelistPattern)] |
|
.map(match => match.groups as FileInfo); |
|
|
|
console.debug(filelist); |
|
|
|
function makeGuid() { |
|
const c = () => (65536 * (1 + Math.random()) | 0).toString(16).substring(1) |
|
return (c() + c() + "-" + c() + "-" + c() + "-" + c() + "-" + c() + c() + c()).toUpperCase(); |
|
} |
|
|
|
function base64Encode(value: string) { |
|
return Buffer.from(value, 'utf-8').toString('base64'); |
|
} |
|
|
|
// base64를 무려 두 번이나 수행하는 매우 강력한 보안 메커니즘 ㄷㄷ |
|
function superStrongEncrypt(value: string): string { |
|
return btoa('R' + base64Encode(value)).replace(/[+]/g, '%2B'); |
|
} |
|
|
|
// Note: |
|
// Dext5Upload 라이브러리에서 요구하는 파라미터 인코딩 방식을 사용 |
|
// |
|
// `Dext5Upload_Config` 객체(예제 사이트에선 `UPLOADTOP.G_CURRUPLOADER._config`)로부터 현재 구성 값(delimiters, handler url, etc) 확인 가능 |
|
// `config.security.encryptParams === "1"` 일 때 아래 인코딩이 유효하고, "0"(비활성화)이면 다른 코드를 사용해야 함 |
|
function makeDownloadRequest(file: FileInfo): Request { |
|
const unitDelimiter = '\v'; |
|
const attributeUnitDelimiter = '\f'; |
|
const fileNameRuleEx = '_'; |
|
const downloadHandlerUrl = new URL('https://opendata.hira.or.kr/dext5upload/handler/upload.dx?callType=download&url=/op/opc/selectOpenData.do'); |
|
|
|
type Attr = [key: string, name: string]; |
|
|
|
function serializeAttr([key, name]: Attr): string { |
|
return key + attributeUnitDelimiter + name; |
|
} |
|
|
|
const attrs: Attr[] = [ |
|
['d01', 'downloadRequest'], |
|
['d10', fileNameRuleEx], |
|
['d25', file.origName], |
|
['d26', Buffer.from(file.webName).toString('latin1')], |
|
['d07', makeGuid()], |
|
]; |
|
const serializedAttrs = attrs.map(serializeAttr).join(unitDelimiter); |
|
const encryptedAttrs = superStrongEncrypt(serializedAttrs); |
|
|
|
return new Request(downloadHandlerUrl, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/x-www-form-urlencoded', |
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', |
|
}, |
|
body: new URLSearchParams({ |
|
customValue: file.filenum, |
|
d00: encryptedAttrs, |
|
}), |
|
}); |
|
} |
|
|
|
async function downloadFile(file: FileInfo) { |
|
const downloadRequest = makeDownloadRequest(file); |
|
|
|
const response = await fetch(downloadRequest); |
|
if (!response.ok) { |
|
console.error('Failed to download %s', file.webName); |
|
return; |
|
} |
|
|
|
await Bun.write(file.webName, response); |
|
} |
|
|
|
// Note: 대역폭이 매우 작으니 하나씩 받을 것 |
|
// 가끔 소켓 끊어먹는 것 같으니 중요한 곳에선 재시도 추가하면 좋음 |
|
for (const file of filelist) { |
|
console.log('Downloading %s', file.webName); |
|
await downloadFile(file); |
|
} |