MovieTracker - 用于跟踪电影和电视节目的 HTA






3.77/5 (12投票s)
用 Javascript 和 VBscript 编写的 HTA 应用程序,用于按发行日期跟踪和排序电影列表
引言
这是 Movie Tracker 应用外观的截图
第 3 列(距离发布的天数)在每次打开应用程序时都会更新,并且电影始终按发布日期排序。这使用户能够跟踪他们想观看的所有电影/连续剧一旦发布 - 列表顶部并显示为绿色的是已经发布的;尚未发布的电影/连续剧仍保留在列表底部并显示为白色。
我的想法是让应用程序尽可能简单,并且只在一个文件中维护 - 因此我创建了一个 HTA 应用程序,该应用程序将电影/连续剧列表保存在 HTML 代码内的 JSON 脚本表中。应用程序关闭后,列表会更新 - 会创建新的 JSON - 并将其保存在文件系统中的*.hta*文件中。这也是一部分代码使用 VBscript 的原因 - 因为 JavaScript 不支持直接访问文件系统。
表是每次加载应用程序时从保存的 JSON 元素列表中从头开始构建的。
使用应用程序
基本应用程序包含一个空表、“添加”按钮、两个用于在电影和连续剧之间切换的按钮,当然还有两个空表 - 一个用于电影,一个用于连续剧 - 当然还有脚本部分。
一次只有一个表可见;可以通过“添加”按钮下方的两个按钮来切换活动表。哪个按钮较大,哪个就是当前选定的(可见的)表。
通过单击“添加”按钮,将打开一个对话框,询问 IMDb URL - 因此用户应输入类似以下内容
在用户添加新的电影或电视节目后,它将出现在相应表的新行中。如果活动表与添加的项目类型不同,活动表将自动更改为项目所在的表。已经发布的电影/连续剧(发布日期已过)将显示为绿色,并且它们将全部按发布日期排序。
每个表行还包含 2 个按钮 - 一个用于刷新行(行被删除并重新获取和解析 IMDb 的信息)和一个用于删除它的按钮。表标题中的“全部刷新”和“全部删除”按钮用于批量刷新或删除当前活动表中的所有项目。
代码执行
每当应用程序打开时,首先读取、解析 JSON 脚本并将表相应地填充。
JSON 对象包含表<td>
元素中包含的所有 HTML 元素。首先,创建一个<tr>
元素,然后为每个 JSON 对创建一个<td>
元素,然后按照给定的顺序将<td>
元素附加到<tr>
元素中 - 列的顺序保存在一个数组中
var colmap = [];
colmap[json_imgurl]=0;
colmap[json_date]=1;
colmap[json_days_left]=2;
colmap[json_url]=3;
colmap[json_description]=4;
colmap[json_button_refreshRow]=5;
colmap[json_button_remove]=6;
这是一个 JSON 对象的示例
{"id":"20191023_tt6450804",
"image":"<img style=\"height: 100px;\" onclick=\"imgClicked()\"
alt=\"Terminator: Dark Fate\"
src=\"https://m.media-amazon.com/images/M/MV5BNzhlYjE5MjMtZDJmYy00MGZmLTgwN2MtZGM0NT
k2ZTczNmU5XkEyXkFqcGdeQXVyMTkxNjUyNQ@@._V1_.jpg\">",
"datePublished":"23 October 2019",
"daysLeft":"Released 87 day(s) ago!",
"url":"<a href=\"https://www.imdb.com/title/tt6450804/\">Terminator: Dark Fate</a>",
"description":"Terminator: Dark Fate is a movie starring Linda Hamilton,
Arnold Schwarzenegger, and Mackenzie Davis. An augmented human and Sarah Connor
must stop an advanced liquid Terminator, from hunting down a young girl, whose fate is...",
"buttonrefreshRow":"<button onclick=\"refreshRow('20191023_tt6450804')\">Refresh</button>",
"buttonRemove":"<button onclick=\"deleteRow('20191023_tt6450804')\">Remove</button>"}
每个项目的 ID 由格式为“yyyymmdd”的发布日期和 IMDb ID(例如 tt6450804)组成;这是为了实现按发布日期排序的可能性,同时也确保 ID 是唯一的。
JSON 中的对象首先被读取到一个名为slist
的数组中,该数组由 2 个元素的数组组成,ID 作为第一个元素,JSON 对象作为第二个元素。然后按 ID(索引为 0 的元素)对slist
进行排序。之后,在一个循环中读取slist
,解析 JSON 对象并(按排序顺序)附加到相应的 HTML 表中。
var slist = [];
getJsonObjects(json).forEach(function(item) {
if (item!='') {
id=getJsonValue(item,'id');
slist.push([id, item]);
}
});
slist.sort(function(a,b) {return a[0]<b[0]});
解析 JSON
for(i = 0; i<slist.length; i++) {
id=slist[i][0];
obj=slist[i][1];
tr=document.createElement('tr');
tr.className = trclass;
tr.setAttribute('id', id);
getJsonPairs(obj).forEach(function(pair) {
if (pair!='') {
// calculate days remaining
namevalue=getJsonNameValue(pair);
if (namevalue[0]=='daysLeft') {
yyyymmdd=convertDate(tds[colmap[json_date]].innerHTML);
namevalue[1]=getTimeRemaining(yyyymmdd);
// movie released
if (dateDiff(getDate(yyyymmdd), new Date())<0) {
tr.setAttribute('style','background-color: #adff2f;');
}
}
td=document.createElement('td');
td.className=unescapeJSON(namevalue[0]);
td.innerHTML=unescapeJSON(namevalue[1]);
tds[colmap[td.className]]=td;
}
});
for(j = 0; j <tds.length; j++) {
tr.appendChild(tds[j]);
}
tbody.appendChild(tr);
}
JSON 对象按以下顺序解析。
首先,通过调用函数getJsonObjects
检索所有 JSON 对象
function getJsonObjects(json) {
var objs=[];
var obj='';
var inside=0;
var c;
var json2=Trim2(json);
do {
if(json2.length>0) {
c = json2.substring(0,1);
json2 = json2.substring(1,json2.length);
} else {
break;
}
if(c=='}') {
inside--;
if(inside==0) {
objs.push(obj);
obj='';
}
}
if(inside>0) {
obj=obj+c;
} else if(inside<0) {
throw('Invalid JSON syntax!');
return [];
}
if(c=='{') inside++;
} while(true);
return objs;
}
接下来,对于每个对象,通过调用函数getJsonPairs
提取键值对
function getJsonPairs(json) {
var pairs=[];
var pair='';
var inside=0;
var inpar=false;
var c;
var cp='';
var json2=Trim2(json);
if(json2.substring(0,1)=='{' && json2.substring(json2.length-1)=='}')
{
json2=json2.substring(1,json2.length);
json2=json2.substring(0, json2.length-1);
}
do {
if(json2.length > 0) {
c = json2.substring(0,1);
json2 = json2.substring(1, json2.length);
} else {
break;
}
if(c=='{' || c=='[') {
inside++;
} else if (c=='}' || c==']') {
inside=inside-1;
} else if (c=='"' && cp!='\\') {
inpar=!inpar;
if(json2.trim()=='') { //last pair
pair+=c;
c=',';
}
}
if(c==',' && inside==0 && !inpar) {
//writeLog(pair);
pairs.push(Trim2(pair));
pair='';
} else {
pair+=c;
}
cp=c;
} while(true);
return pairs;
}
然后,对于每个键值对,调用函数getJsonNameValue
,该函数返回一个包含 2 个元素的数组,第一个元素是名称,第二个元素是值
function getJsonNameValue(json) {
var namevalue=['',''];
var c;
var cp='';
var inpar=false;
var json2=Trim2(json);
do {
if(json2.length > 0) {
c = json2.substring(0,1);
json2 = json2.substring(1);
} else {
break;
}
if(c=='"' && cp!='\\') {
inpar=!inpar;
} else if (c==':' && !inpar) {
namevalue[1]=json2;
break;
}
namevalue[0]=namevalue[0]+c;
cp=c;
} while(true);
namevalue[0]=Trim2(namevalue[0]);
namevalue[1]=Trim2(namevalue[1]);
// get rid of quotes
if(namevalue[0].substring(0,1)=='"') namevalue[0]=namevalue[0].substring(1);
if(namevalue[1].substring(0,1)=='"') namevalue[1]=namevalue[1].substring(1);
if(namevalue[0].substring(namevalue[0].length-1)=='"')
namevalue[0]=namevalue[0].substring(0, namevalue[0].length-1);
if(namevalue[1].substring(namevalue[1].length-1)=='"')
namevalue[1]=namevalue[1].substring(0, namevalue[1].length-1);
return namevalue;
}
添加新项目
当单击“添加”按钮时,将调用函数add(url)
。
function add(url) {
if(dragCheck) return;
if(url=='') {
var url=prompt('Enter IMDb URL:','');
}
if(url==null || url=='') return;
var id=getImdbId(url);
var imdbid=id;
if(id=='') {
alert('Invalid URL!');
writeLog('Invalid URL!');
return;
}
// check if item already exists
var trs;
var tb;
for(cnt=0;cnt<=1;cnt++)
{
if(i==0) tb=tbody_m;
else tb=tbody_s;
trs=tb.getElementsByClassName(trclass);
for(i=0;i<trs.length;i++) {
if (trs[i].getAttribute('id').length!=trs[i].getAttribute('id').
replace(id,'').length) {
alert('Item \"'+id+'\" already exists!');
return;
}
}
}
// read info from Imdb
writeLog('add: Reading JSON from IMDb');
writeLog('---------------------------');
var imdbJSON;
try {
imdbJSON=HttpSearch(url, String.fromCharCode(60)+
'script type=\"application/ld+json\"'+String.fromCharCode(62),
String.fromCharCode(60)+'/script'+String.fromCharCode(62));
}
catch (err) {
writeLog('ERROR: '+err);
alert("Not found!");
return;
}
var ms=getJsonValue(imdbJSON,json_type);
if (ms==json_type_m) {
if(active_tbody==tbody_s) change_tab(b_m_id);
} else {
if(active_tbody==tbody_m) change_tab(b_s_id);
}
writeLog(' type='+ms);
var namevalue;
var tr, td;
tr=document.createElement('tr');
tr.className = trclass;
var tds = [colmap.length];
var name, thumbnailUrl;
getJsonPairs(imdbJSON).forEach(function(pair) {
namevalue=getJsonNameValue(pair);
namevalue[0]=unescapeJSON(unicodeToChar(namevalue[0]));
namevalue[1]=unescapeJSON(unicodeToChar(namevalue[1]));
switch(namevalue[0]) {
case json_name:
name=namevalue[1];
break;
case json_url:
url="https://www.imdb.com"+namevalue[1];
break;
case json_description:
td=document.createElement('td');
td.className=json_description;
td.innerHTML=namevalue[1];
tds[colmap[json_description]]=td;
// build next episode div (for series)
if(ms==json_type_s) {
var episodeDesc=getNextEpisode(imdbid);
var dt;
if(episodeDesc[0]=='' && episodeDesc[1]==0) episodeDesc=episodeDesc[3];
else {
if(episodeDesc[0]!='') dt=episodeDesc[0].substring(6,8) +
'.' + episodeDesc[0].substring(4,6) + '.' +
episodeDesc[0].substring(0,4);
else dt='Unknown';
episodeDesc='Episode #' + episodeDesc[1].toString() +
' <b>' + episodeDesc[2] + '</b><br><i>Date: ' + dt +
'</i><br><br>' + episodeDesc[3];
}
tds[colmap[json_description]].innerHTML += '<br>' +
createCollapsible(episodeDesc);
var coll = tds[colmap[json_description]].
getElementsByClassName('collapsible')[0];
coll.addEventListener('click', function() {
this.classList.toggle('collapsed');
var div = this.nextElementSibling;
if (div.style.maxHeight){
div.style.maxHeight = null;
} else {
div.style.maxHeight = div.scrollHeight + 'px';
}
});
}
writeLog(' '+td.className+'='+td.innerHTML);
break;
case json_imgurl:
thumbnailUrl=namevalue[1];
break;
case json_date:
namevalue[1]=namevalue[1].replace(/-/g,'');
id=namevalue[1]+'_'+id;
tr.setAttribute('id', id);
td=document.createElement('td');
td.className=json_date;
td.innerHTML=convertDate(namevalue[1]);
tds[colmap[json_date]]=td;
// is movie released?
if(dateDiff(getDate(namevalue[1]), new Date())<0) {
tr.setAttribute('style','background-color: #adff2f;');
}
writeLog(' '+td.className+'='+td.innerHTML);
td=document.createElement('td');
td.className=json_days_left;
td.innerHTML=getTimeRemaining(namevalue[1]);
tds[colmap[json_days_left]]=td;
writeLog(' '+td.className+'='+td.innerHTML);
break;
default:
break;
}
});
// create url td
td=document.createElement('td');
td.className=json_url;
td.innerHTML=createNameUrl(url, name);
tds[colmap[json_url]]=td;
writeLog(' '+td.className+'='+td.innerHTML);
// create thumbnail td
td=document.createElement('td');
td.className=json_imgurl;
td.innerHTML=createPosterImg(thumbnailUrl,name);
tds[colmap[json_imgurl]]=td;
writeLog(' '+td.className+'='+td.innerHTML);
// create buttonrefreshRow
td=document.createElement('td');
td.className=json_button_refreshRow;
td.innerHTML=createButtonRefresh(id);
tds[colmap[json_button_refreshRow]]=td;
writeLog(' '+td.className+'='+td.innerHTML);
// create buttonRemove
td=document.createElement('td');
td.className=json_button_remove;
td.innerHTML=createButtonRemove(id);
tds[colmap[json_button_remove]]=td;
writeLog(' '+td.className+'='+td.innerHTML);
for(j = 0; j<tds.length; j++) {
tr.appendChild(tds[j]);
}
// append the new row to the correct place in the table
var r_next = null;
var rows=active_tbody.getElementsByClassName('row');
for(i=0;i<rows.length;i++) {
if(rows[i].getAttribute('id') > tr.getAttribute('id')) {
r_next = rows[i];
break;
}
}
if(r_next==null) {
active_tbody.appendChild(tr);
writeLog('Added '+tr.getAttribute('id'));
} else {
r_next.parentNode.insertBefore(tr, r_next);
writeLog('Added '+tr.getAttribute('id')+' before '+r_next.getAttribute('id'));
}
writeLog(' ');
}
此函数是整个代码的核心。它需要一个 IMDb URL 作为输入 - 该 URL 可以作为参数传递,或者,如果不是这种情况,则使用prompt
函数向用户询问。
add function
将首先尝试使用 http 请求/响应(XMLHttpRequest
对象)检索整个 IMDb HTML 文档。然后它将从响应中提取 JSON 脚本,解析 JSON,获取必要的键值对,构建<tr>
和<td>
元素,并将<tr>
添加到相应的表中。
这是add
函数中使用的一些函数的描述
ImdbSearch(url, startstring, endstring)
– 此函数使用提供的 URL 发起GET
请求,在返回的流中查找startstring
的第一个实例,然后查找endstring
的第一个后续实例,并返回两者之间的所有内容。在以前的版本中,此函数曾多次用于检索电影的每个特定信息,现在仅用于检索 JSON 脚本;之后,所有其他信息都通过解析 JSON 进行检索GetImdbId(url)
– 从 URL 中提取 IMDb IDconvertDate(datestring)
– 在数字(yyyymmdd)和字符串(dd monthname yyyy)日期表示之间进行转换GetKey(datenum, imdbid)
– 从数字日期(yyyymmdd)和 IMDb ID 创建一个键getNextEpisode(imdbid)
- 获取下一集信息(针对连续剧) - 请参阅下一段!createCollapsible(description)
- 创建一个可折叠的div
元素,该元素将添加到连续剧表的描述列中
解析下一集信息
连续剧还提取了额外的信息 - 下一集的信息。此信息在函数getNextEpisode
中检索和提取,该函数从add
函数调用。
此函数从 IMDb 的剧集指南中检索 HTML 内容,即:
通过使用 DOMParser 对象,它通过解析类为“info
”的div
元素来获取所有必要的信息。它提取下一集的播出日期、剧集编号、标题和描述。它返回一个字符串数组。
function getNextEpisode(id) {
writeLog('getNextEpisode for ' + id);
var parser=new DOMParser();
var url=getImdbUrl(id) + 'episodes';
var text=HttpSearch(url,'','');
var doc=parser.parseFromString(text,'text/html');
var episodes = [];
var divs=doc.getElementsByClassName('info');
var item;
var defaultDate='99990101';
var airdate, episodeNumber, title, description;
for(i=0;i<divs.length;i++) {
item = divs[i];
airdate='';
try {
airdate=item.getElementsByClassName('airdate')[0].innerHTML.trim();
if(!isNaN(airdate) && airdate.length==4) { // only year
airdate=defaultDate;
}
else {
airdate=airDateYYYYMMDD(airdate);
}
}
catch(err) {
}
episodeNumber=0;
try {
episodeNumber=parseInt(item.getElementsByTagName
('meta')[0].getAttribute('content'),10);
}
catch(err) {
}
title='';
try {
title=item.getElementsByTagName('strong')[0].getElementsByTagName
('a')[0].getAttribute('title');
}
catch(err) {
}
description='';
try {
description=item.getElementsByClassName('item_description')[0];
if(description.getElementsByTagName('a').length>0)
description.removeChild(description.getElementsByTagName('a')[0]);
description=description.innerHTML.trim();
}
catch(err) {
}
episodes.push([airdate,episodeNumber,title,description]);
}
writeLog(' Found ' + episodes.length + ' episodes');
if(episodes==null || episodes.length==0) {
description='Unable to find last episode!';
writeLog(' ' + description);
return ['',0,'',description];
}
// filter only episodes that are in the future
var today=(new Date()).toISOString().substring(0,10).replace(/-/g,'');
var new_episodes=episodes.filter(function(e) {
return e[0]>=today;
});
writeLog(' ' + episodes.length + ' in future');
if(new_episodes.length>0)
{
new_episodes.sort(function(a,b) {return a[0]<b[0]});
var maxdate=[];
// check if there are multiple episodes on same date
for(i=0;i<new_episodes.length;i++) {
if(new_episodes[i][0]==new_episodes[0][0]) maxdate.push(new_episodes[i]);
}
if(maxdate.length==0) maxdate.push(['',0,'','']);
if(maxdate.length>1) {
maxdate.sort(function(a,b) {return a[1]<b[1]});
}
if(maxdate[0][0]==defaultDate) maxdate[0][0]='';
writeLog(' date='+maxdate[0][0]);
writeLog(' episodeNumber='+maxdate[0][1]);
writeLog(' name='+maxdate[0][2]);
writeLog(' description='+maxdate[0][3]);
return maxdate[0];
}
else {
description='No more episodes for this season...';
writeLog(' ' + description);
return ['',0,'',description];
}
}
删除一个项目
创建的每个tr
元素都将包含一个“删除”按钮。如果单击此按钮,将调用函数deleteRow
,并将该特定行的 ID 传递给它。该函数仅从<tbody>
中删除具有给定 ID 的<tr>
子元素。
function deleteRow(id) {
var tr=document.getElementById(id);
tr.parentNode.removeChild(tr);
}
刷新项目
每个tr
元素还将包含一个“刷新”按钮。单击此按钮时,将调用函数refreshRow
。此操作旨在刷新特定项目的数据 - 以防自添加或上次刷新以来发生更改。该子程序本身不执行刷新,而是会删除现有行,然后再次添加它,并附带来自 IMDb 的最新数据。
function refreshRow(id) {
var tr=document.getElementById(id);
var url=tr.getElementsByClassName(json_url)[0].getElementsByTagName
('a')[0].getAttribute('href');
deleteRow(id);
add(url);
}
保存更改
tbody
元素有一个用于DOMSubtreeModified
事件的事件侦听器 - 这意味着如果tbody
的 DOM 结构发生任何更改,将调用函数showsave
。此函数使savebutton
可见,以便用户能够保存(如果他们愿意)更改(即保存已添加、删除或刷新的内容)。
尽管savebutton
事件处理程序本身位于 JavaScript 部分(save
函数)中,但实际的保存部分是在 VBscript 部分的saveChanges
子程序中完成的。必须使用 VBscript 来完成此操作,因为 JavaScript 不允许直接访问客户端文件系统。
项目保存在 HTA 应用程序本身中,保存在相应的script
标签内
<script id="json_movies" type="application/ld+json"></script>
<script id="json_series" type="application/ld+json"></script>
创建 JSON 的函数
Function makeJSON(ms)
makeJSON = ""
Dim tbody
If ms=json_type_m Then
Set tbody=tbody_m
Else
Set tbody=tbody_s
End If
For Each tr In tbody.getelementsbyclassname(trclass)
makeJSON=makeJSON&"{""id"":"""&tr.getattribute("id")&""","
For Each td In tr.getelementsbytagname("td")
makeJSON=makeJSON & """" & escapeJSON(td.className) & """:"""
makeJSON=makeJSON & escapeJSON(Trim(td.innerhtml))
makeJSON=makeJSON & ""","
Next
makeJSON=Left(makeJSON,Len(makeJSON)-1)
makeJSON=makeJSON & "}," & Chr(13) & Chr(10)
Next
If makeJSON <> "" Then makeJSON=Left(makeJSON,Len(makeJSON)-3)&Chr(13) & Chr(10)
End Function
HTA 应用程序将打开文件系统中的自身文件,并重写自己的代码。这样,所有内容始终方便地保存在一个文件中 - 应用程序和数据。
文件路径通过使用document.location.pathname
属性获得
path = Right(document.location.pathname,Len(document.location.pathname)-1)
可选功能 - 读取输入文件
应用程序中内置了一项可选功能 - 可以指定一个外部输入文件,如果设置了该文件,每次调用应用程序时都会读取它。
输入文件应包含按行排列的 IMDb ID,如下所示
tt4520988
tt8946378
tt5180504
tt9173418
此功能旨在“远程”方便地向应用程序添加项目 - 由于应用程序未托管在服务器上,因此无法从任何地方访问,但只能在本地访问,您可以将 txt 文件设置在云服务(例如 Dropbox)上,并随时随地编辑此文件。然后,应用程序在打开后,将拾取此 txt 文件中编写的所有内容。
显然,此代码也是用 VBscript 编写的,因为它需要访问本地文件系统。
可选功能 - 调试模式
应用程序中还内置了一个调试模式,可以通过将变量debugmode
更改为true
来启用。在这种情况下,在代码执行期间,应用程序将在 HTML 主体末尾的特殊段落中写出一些信息。
<P id=dbg style="FONT-SIZE: 10px; FONT-FAMILY: courier; COLOR: red"> </P>
历史
- 2019 年 6 月 23 日:初始版本
- 2020 年 1 月 18 日:第 2 版 - 完全重新设计代码,添加了刷新选项和电视节目支持,列表存储在 JSON 中
- 2020 年 1 月 26 日 - 大部分代码已转为 JavaScript,添加了
savebutton
,添加了可拖动按钮功能,添加了可选的外部输入文件功能 - 2020 年 3 月 8 日 - 添加了下一集描述(解析和可折叠
div
)