65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.77/5 (12投票s)

2019 年 6 月 25 日

CPOL

8分钟阅读

viewsIcon

26518

downloadIcon

474

用 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('&nbsp;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('&nbsp;&nbsp;'+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('&nbsp;&nbsp;'+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('&nbsp;&nbsp;'+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('&nbsp;&nbsp;'+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('&nbsp;&nbsp;'+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('&nbsp;&nbsp;'+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('&nbsp;&nbsp;'+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('&nbsp;');
}

此函数是整个代码的核心。它需要一个 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 ID
  • convertDate(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">&nbsp;</P>

历史

  • 2019 年 6 月 23 日:初始版本
  • 2020 年 1 月 18 日:第 2 版 - 完全重新设计代码,添加了刷新选项和电视节目支持,列表存储在 JSON 中
  • 2020 年 1 月 26 日 - 大部分代码已转为 JavaScript,添加了savebutton,添加了可拖动按钮功能,添加了可选的外部输入文件功能
  • 2020 年 3 月 8 日 - 添加了下一集描述(解析和可折叠div
© . All rights reserved.