android 项目实战——超级课程表课表一键提取功能

如果你是在校大学生,或许你用多了各种课程表,比如课程格子,超级课程表。它们都有一个共同点就是可以一键导入教务处的课程。那么一直都是用户的我们,没有考虑过它是如何实现的。那么现在就来模仿一款”超级课程表“。

PS:由于超级课程表是商用软件,原本提取了一些图片,但是为了避免涉及侵权问题,所有图片均已使用一张绿色圆圈代替,背景图片也以颜色代替,缺乏美观,如果你觉得太丑,可以自己寻找图片代替。

那么说了这么久,先来看看这款高仿的软件长什么样子。本文的代码做过精简,所以界面可能有出入。

技术分享

好了,界面太丑,不忍直视,先暂时忽略,本文的重点不是UI,而是如何提取课程。

先做下准备工作。

  1. HttpWatch抓包分析工具。此工具的使用后文介绍

  2. Litepal数据持久化orm,郭大神的大作,挺好用的orm,用法详见郭霖博客。

  3. Async-android-http 数据异步请求框架,这里主要用到这个框架的异步请求以及session保持的功能,或许大多数人没有使用过这个框架的会话保持功能,反正个人觉得就是一神器,操作十分简单,就1句话,不然用HttpClient可能就没那么简单了,要自己写好多内容。具体用法参见github

  4. Jsoup网页内容解析框架,可支持jquery选择器。可以支持从本地加载html,远程加载html,支持数据抽取,数据修改等功能,如果能灵活运用这个框架,那么你想抓取什么东西都不在话下。


既然要导入课程表,那么一定要登录教务处,结论是需要教务处的账号密码,这个好办,每个学生都有账号密码。那么怎么登录呢,这个当然不是我们人工登录了,只要提供账号密码,由程序来帮我们完成登录过程以及课程的提取过程。如果登录?首先打开教务处登录界面,打开HttpWatch进行跟踪。输入账号,密码,验证码(验证码视具体学校不同,有些学校不含验证码,有些学校含验证码,验证码的处理后文进行说明),输入完成后点击登录,再点击查看课程的菜单,之后停止HttpWatch录制,把文件保存一下进行分析。打开保存后的文件,查看登录时提交的参数及一些信息,记录下来,同时记录查看课程页提交的参数及信息。

技术分享


先看登录页面提交的参数,参数均是POST提交,这可以通过HttpWatch看到提交方式

__VIEWSTATE:有这个值页面生成的,这里我直接使用这个固定值而不去抓取,这个值是.net根据表单参数自动生成的。理论上同一个页面是不会变动的。

Button1:传空值即可

hidPdrs:传空值即可

lbLanguage:传空值即可

RadioButtonList1:图上是乱码,通过查看网页源代码可知该值是学生,因为我们是以学生的角色登录的

TextBox2:这个值是密码,传密码即可

txtSecrect:这个值是验证码,传对应的验证码即可

txtUserName:这个值是学号,传学号即可

你以为只要提交这些参数就好了吗,那么你就错了,我们还有设置请求头信息,如下图技术分享

我们不必设置所有请求头信息,只需要设置Host,Referer,User-Agent(可不设)。


请求头设置完毕了,那么来说一个重大的问题,就是验证码的问题,这里有三种方式供选择。

  1. 在登录之前抓取验证码,显示出来,供用户输入。

  2. 使用正方的bug1,为什么是bug1呢,因为后面一种方法利用了bug2,bug1,bug2不一定所有学校适用,正方的默认登录页面是default2.aspx,如果这个页面有验证码,你可以试试default1.aspx-default6.aspx六个页面,运气好的话可能会有不需要验证码的页面。这时候你使用该页面进行登录即可(提交参数会不同,具体自己抓包分析)

  3. 使用正方的bug2,不得不说这个bug2,大概是某个程序猿在某男某月某日无意间留下的把,那么怎么使用这个bug呢,很简单,登录的时候直接传验证码为空值或者空字符串过去就好了,有人说,你他妈逗我,这都行,恩,真的行。为什么行呢,原因可能是正方后台程序没有判断传过来的值是不是空。我们模拟登录的时候并没有去请求验证码的页面,所有不会产生验证码(此时为空字符串或者空值)和cookie,当我们提交空验证码时,后台接收到的值就是空字符串,两个空字符串做比较当然相等了,以上只是猜测,毕竟正方是.net的,.net的处理机制本人不是很清楚。


说了这么多理论知识,来点实际的把,先完成登录界面的代码

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="${relativePackage}.${activityClass}" >

    
    <ImageView 
        android:id="@+id/logo"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/icon"
        android:layout_alignParentTop="true"
        android:layout_centerHorizontal="true"
        android:layout_marginTop="30dp"
        />
    <EditText
        android:id="@+id/username"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/logo"
        android:layout_marginTop="50dp"
        android:drawableLeft="@drawable/username"
        android:hint="教务处账号"
        android:text="" />

    <EditText
        android:id="@+id/password"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/username"
        android:drawableLeft="@drawable/password"
        android:hint="教务处密码"
        android:text=""
        android:password="true" />

    <LinearLayout
        android:id="@+id/ll_code"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/password"
        android:orientation="horizontal" 
     	android:visibility="gone"
        >

        <EditText
            android:id="@+id/secrectCode"
            android:layout_width="100dp"
            android:layout_height="wrap_content"
            android:hint="验证码" />

        <ImageView
            android:id="@+id/codeImage"
            android:layout_width="72dp"
            android:layout_height="36dp"
            android:layout_marginLeft="10dp"
            android:layout_marginRight="10dp"
            android:scaleType="fitStart" />

        <Button
            android:id="@+id/getCode"
            android:layout_width="100dp"
            android:layout_height="40dp"
            android:background="@drawable/btn_login_selector"
            android:text="刷新验证码"
            android:textColor="#fff" />
    </LinearLayout>

    <Button
        android:id="@+id/login"
        android:layout_width="180dp"
        android:layout_height="45dp"
        android:layout_alignParentBottom="true"
        android:layout_below="@drawable/password"
        android:layout_centerHorizontal="true"
        android:layout_marginBottom="100dp"
        android:background="@drawable/btn_login_selector"
        android:text="登录"
        android:textColor="#fff" />

</RelativeLayout>

很简单,就是账号,密码,以及验证码,这里验证码被我隐藏了,因为我使用了bug2,不需要请求验证码,对应的界面隐藏掉,但是如果你把他显示出来,获取验证码让用户输入也是可以的。

在登录之前先初始化一下cookie,这一步必须在请求之前设置。


/**
	 * 初始化Cookie
	 */
	private void initCookie(Context context) {
		//必须在请求前初始化
		cookie = new PersistentCookieStore(context);
		HttpUtil.getClient().setCookieStore(cookie);
	}

那么HttpUtil又是什么呢,很简单,就是一个请求用的工具类

package cn.lizhangqu.kb.util;
 
import org.apache.http.Header;
 
import android.app.ProgressDialog;
import android.content.Context;
import android.widget.Toast;
import cn.lizhangqu.kb.service.LinkService;
 
import com.loopj.android.http.AsyncHttpClient;
import com.loopj.android.http.AsyncHttpResponseHandler;
import com.loopj.android.http.BinaryHttpResponseHandler;
import com.loopj.android.http.RequestParams;
 
/**
 * Http请求工具类
 * @author lizhangqu
 * @date 2015-2-1
 */
/**
 * @author Administrator
 *
 */
public class HttpUtil {
    private static AsyncHttpClient client = new AsyncHttpClient(); // 实例话对象
    // Host地址
    public static final String HOST = "***.***.***.***";
    // 基础地址
    public static final String URL_BASE = "http://***.***.***.***/";
    // 验证码地址
    public static final String URL_CODE = "http://***.***.***.***/CheckCode.aspx";
    // 登陆地址
    public static final String URL_LOGIN = "http://***.***.***.***/default2.aspx";
    // 登录成功的首页
    public static String URL_MAIN = "http://***.***.***.***/xs_main.aspx?xh=XH";
    // 请求地址
    public static String URL_QUERY = "http://***.***.***.***/QUERY";
 
    /**
     * 请求参数
     */
    public static String Button1 = "";
    public static String hidPdrs = "";
    public static String hidsc = "";
    public static String lbLanguage = "";
    public static String RadioButtonList1 = "学生";
    public static String __VIEWSTATE = "dDwyODE2NTM0OTg7Oz7YiHv1mHkLj1OkgkF90IvNTvBrLQ==";
    public static String TextBox2 = null;
    public static String txtSecretCode = null;
    public static String txtUserName = null;
 
    // 静态初始化
    static {
        client.setTimeout(10000); // 设置链接超时,如果不设置,默认为10s
        // 设置请求头
        client.addHeader("Host", HOST);
        client.addHeader("Referer", URL_LOGIN);
        client.addHeader("User-Agent",
                "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko");
    }
 
    /**
     * get,用一个完整url获取一个string对象
     *
     * @param urlString
     * @param res
     */
    public static void get(String urlString, AsyncHttpResponseHandler res) {
        client.get(urlString, res);
    }
 
    /**
     * get,url里面带参数
     *
     * @param urlString
     * @param params
     * @param res
     */
    public static void get(String urlString, RequestParams params,
            AsyncHttpResponseHandler res) {
        client.get(urlString, params, res);
    }
 
    /**
     * get,下载数据使用,会返回byte数据
     *
     * @param uString
     * @param bHandler
     */
    public static void get(String uString, BinaryHttpResponseHandler bHandler) {
        client.get(uString, bHandler);
    }
 
    /**
     * post,不带参数
     *
     * @param urlString
     * @param res
     */
    public static void post(String urlString, AsyncHttpResponseHandler res) {
        client.post(urlString, res);
    }
 
    /**
     * post,带参数
     *
     * @param urlString
     * @param params
     * @param res
     */
    public static void post(String urlString, RequestParams params,
            AsyncHttpResponseHandler res) {
        client.post(urlString, params, res);
    }
 
    /**
     * post,返回二进制数据时使用,会返回byte数据
     *
     * @param uString
     * @param bHandler
     */
    public static void post(String uString, BinaryHttpResponseHandler bHandler) {
        client.post(uString, bHandler);
    }
 
    /**
     * 返回请求客户端
     *
     * @return
     */
    public static AsyncHttpClient getClient() {
        return client;
    }
 
    /**
     * 获得登录时所需的请求参数
     *
     * @return
     */
    public static RequestParams getLoginRequestParams() {
        // 设置请求参数
        RequestParams params = new RequestParams();
        params.add("__VIEWSTATE", __VIEWSTATE);
        params.add("Button1", Button1);
        params.add("hidPdrs", hidPdrs);
        params.add("hidsc", hidsc);
        params.add("lbLanguage", lbLanguage);
        params.add("RadioButtonList1", RadioButtonList1);
        params.add("TextBox2", TextBox2);
        params.add("txtSecretCode", txtSecretCode);
        params.add("txtUserName", txtUserName);
        return params;
    }
 
    /**
     * 接口回调
     * @author lizhangqu
     *
     * 2015-2-22
     */
    public interface QueryCallback {
        public String handleResult(byte[] result);
    }
 
    /**
     * 登录后查询信息封装好的函数
     * @param context
     * @param linkService
     * @param urlName
     * @param callback
     */
    public static void getQuery(final Context context, LinkService linkService,
            final String urlName, final QueryCallback callback) {
        final ProgressDialog dialog = CommonUtil.getProcessDialog(context,
                "正在获取" + urlName);
        dialog.show();
        String link = linkService.getLinkByName(urlName);
        if (link != null) {
            HttpUtil.URL_QUERY = HttpUtil.URL_QUERY.replace("QUERY", link);
        } else {
            Toast.makeText(context, "链接出现错误", Toast.LENGTH_SHORT).show();
            return;
        }
        HttpUtil.getClient().addHeader("Referer", HttpUtil.URL_MAIN);
        HttpUtil.getClient().setURLEncodingEnabled(true);
        HttpUtil.get(HttpUtil.URL_QUERY, new AsyncHttpResponseHandler() {
            @Override
            public void onSuccess(int arg0, Header[] arg1, byte[] arg2) {
                if (callback != null) {
                    callback.handleResult(arg2);
                }
                Toast.makeText(context, urlName + "获取成功!!!", Toast.LENGTH_LONG)
                        .show();
                dialog.dismiss();
            }
 
            @Override
            public void onFailure(int arg0, Header[] arg1, byte[] arg2,
                    Throwable arg3) {
                dialog.dismiss();
                Toast.makeText(context, urlName + "获取失败!!!", Toast.LENGTH_SHORT)
                        .show();
            }
        });
    }
}

地址信息被我处理掉了,替换成对应的地址即可,都是几个简单的函数,其中最后一个函数做了一个封装,代码自己读吧,这里就不讲了。。。。。

现在查看登录的代码。

/**
     * 登录
     */
    private void login() {
        HttpUtil.txtUserName = username.getText().toString().trim();
        HttpUtil.TextBox2 = password.getText().toString().trim();
        //需要时打开验证码注释
        //HttpUtil.txtSecretCode = secrectCode.getText().toString().trim();
        if (TextUtils.isEmpty(HttpUtil.txtUserName)
                || TextUtils.isEmpty(HttpUtil.TextBox2)) {
            Toast.makeText(getApplicationContext(), "账号或者密码不能为空!",
                    Toast.LENGTH_SHORT).show();
            return;
        }
        final ProgressDialog dialog =CommonUtil.getProcessDialog(LoginActivity.this,"正在登录中!!!");
        dialog.show();
        RequestParams params = HttpUtil.getLoginRequestParams();// 获得请求参数
        HttpUtil.URL_MAIN = HttpUtil.URL_MAIN.replace("XH",
                HttpUtil.txtUserName);// 获得请求地址
        HttpUtil.getClient().setURLEncodingEnabled(true);
        HttpUtil.post(HttpUtil.URL_LOGIN, params,
                new AsyncHttpResponseHandler() {
 
                    @Override
                    public void onSuccess(int arg0, Header[] arg1, byte[] arg2) {
                        try {
                            String resultContent = new String(arg2, "gb2312");
                            if(linkService.isLogin(resultContent)!=null){
                                String ret = linkService.parseMenu(resultContent);
                                Log.d("TAG", "login success:"+ret);
                                Toast.makeText(getApplicationContext(),
                                        "登录成功!!!", Toast.LENGTH_SHORT).show();
                                jump2Main();
 
                            }else{
                                Toast.makeText(getApplicationContext(),"账号或者密码错误!!!", Toast.LENGTH_SHORT).show();
                            }
 
                        } catch (UnsupportedEncodingException e) {
                            e.printStackTrace();
                        } finally {
                            dialog.dismiss();
                        }
                    }
                    @Override
                    public void onFailure(int arg0, Header[] arg1, byte[] arg2,
                            Throwable arg3) {
                        Toast.makeText(getApplicationContext(), "登录失败!!!!",
                                Toast.LENGTH_SHORT).show();
                        dialog.dismiss();
                    }
                });
    }
通过抓取关键字,判断是否登录成功,登录成功则解析菜单,对应的逻辑被我封装在service层里了
package cn.lizhangqu.kb.service;
 
import java.util.List;
 
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.litepal.crud.DataSupport;
 
import cn.lizhangqu.kb.model.Course;
import cn.lizhangqu.kb.model.LinkNode;
 
/**
 * LinNode表的业务逻辑处理
 * @author lizhangqu
 * @date 2015-2-1
 */
public class LinkService {
    private static volatile LinkService linkService;
    private LinkService(){}
    public static LinkService getLinkService() {
        if(linkService==null){
            synchronized (LinkService.class) {
                if(linkService==null)
                    linkService=new LinkService();
            }
        }
 
        return linkService;
    }
 
    public String getLinkByName(String name){
        List<LinkNode> find = DataSupport.where("title=?",name).limit(1).find(LinkNode.class);
        if(find.size()!=0){
            return find.get(0).getLink();
        }else{
            return null;
        }
    }
    public boolean save(LinkNode linknode){
        return linknode.save();
    }
    /**
     * 查询所有链接
     *
     * @return
     */
    public List<LinkNode> findAll() {
        return DataSupport.findAll(LinkNode.class);
    }
    public String parseMenu(String content) {
        LinkNode linkNode =null;
        StringBuilder result = new StringBuilder();
        Document doc = Jsoup.parse(content);
        Elements elements = doc.select("ul.nav a[target=zhuti]");
        for (Element element : elements) {
            result.append(element.html() + "\n" + element.attr("href") + "\n\n");
            linkNode= new LinkNode();
            linkNode.setTitle(element.text());
            linkNode.setLink(element.attr("href"));
            save(linkNode);
        }
        return result.toString();
 
    }
    public String isLogin(String content){
        Document doc = Jsoup.parse(content, "UTF-8");
        Elements elements = doc.select("span#xhxm");
        try{
            Element element=elements.get(0);
            return element.text();
        }catch(IndexOutOfBoundsException e){
            //e.printStackTrace();
        }
        return null;
    }
}

判断是否登录成功的判断依据是看页面上是否有某某同学,欢迎你,这段信息在id为xhxm的span里,成功后解析菜单,因为不一定只是抓课表,也可能抓成绩,各种抓,所以这里把链接都记录下来,对应页面的源代码我会和代码一同上传。

如果你要使用验证码,则获取验证码即可,对应代码如下,就是获得验证码后显示在界面上

/**
     * 获得验证码
     */
    private void getCode() {
        final ProgressDialog dialog =CommonUtil.getProcessDialog(LoginActivity.this,"正在获取验证码");
        dialog.show();
        HttpUtil.get(HttpUtil.URL_CODE, new AsyncHttpResponseHandler() {
            @Override
            public void onSuccess(int arg0, Header[] arg1, byte[] arg2) {
 
                InputStream is = new ByteArrayInputStream(arg2);
                Bitmap decodeStream = BitmapFactory.decodeStream(is);
                code.setImageBitmap(decodeStream);
                Toast.makeText(getApplicationContext(), "验证码获取成功!!!",Toast.LENGTH_SHORT).show();
                dialog.dismiss();
            }
 
            @Override
            public void onFailure(int arg0, Header[] arg1, byte[] arg2,
                    Throwable arg3) {
 
                Toast.makeText(getApplicationContext(), "验证码获取失败!!!",
                        Toast.LENGTH_SHORT).show();
                dialog.dismiss();
 
            }
        });
    }

LinkUtil里面是一些常量

package cn.lizhangqu.kb.util;
 
/**
 * 首页菜单接口
 * 用于定义linknode表中的标题
 * @author lizhangqu
 * @date 2015-2-1
 */
public interface LinkUtil {
    public static final String ZYXXK="专业选修课";
    public static final String QXXGXK="全校性公选课(通识限选)";
    public static final String SYXK="实验选课";
    public static final String DJKSBM="等级考试报名";
    public static final String GRXX="个人信息";
    public static final String MMXG="密码修改";
    public static final String XSGRKB="学生个人课表";
    public static final String XSKSCX="学生考试查询";
    public static final String CJCX="成绩查询";
    public static final String DJKSCX="等级考试查询";
    public static final String JCSYXX="教材使用信息";
    public static final String XSXKQKCX="学生选课情况查询";
    public static final String XSBKKSCX="学生补考考试查询";
    public static final String XSXXYPJ="学生信息员评价";
    public static final String FKJGCX="反馈结果查询";
    public static final String JWGG="教务公告";
    public static final String BMJSKBCX="部门教师课表查询";
    public static final String QXKBCX="全校课表查询";
    public static final String JXRLCX="教学日历查询";
}

接下来是文章的重点,即如何解析课表。

课表是在一张table里的,提取table里的内容进行解析,解析方法不止一种,我在解析过程中也尝试了多种方法,直接看代码把

/**
     * 根据网页返回结果解析课程并保存
     *
     * @param content
     * @return
     */
    public String parseCourse(String content) {
        StringBuilder result = new StringBuilder();
        Document doc = Jsoup.parse(content);
 
        Elements semesters = doc.select("option[selected=selected]");
        String[] years=semesters.get(0).text().split("-");
        int startYear=Integer.parseInt(years[0]);
        int endYear=Integer.parseInt(years[1]);
        int semester=Integer.parseInt(semesters.get(1).text());
 
 
 
        Elements elements = doc.select("table#Table1");
        Element element = elements.get(0).child(0);
        //移除一些无用数据
 
        element.child(0).remove();
        element.child(0).remove();
        element.child(0).child(0).remove();
        element.child(4).child(0).remove();
        element.child(8).child(0).remove();
        int rowNum = element.childNodeSize();
        int[][] map = new int[11][7];
        for (int i = 0; i < rowNum - 1; i++) {
            Element row = element.child(i);
            int columnNum = row.childNodeSize() - 2;
            for (int j = 1; j < columnNum; j++) {
                Element column = row.child(j);
                int week = fillMap(column, map, i);
                //填充map,获取周几,第几节至第几节
                //作用:弥补不能获取这些数据的格式
                if (column.hasAttr("rowspan")) {
                    try {
                        System.out.println("周"+ week+ " 第"+ (i + 1)+ "节-第"+ (i + Integer.parseInt(column.attr("rowspan"))) + "节");
                        splitCourse(column.html(), startYear,endYear,semester,week, i + 1,i + Integer.parseInt(column.attr("rowspan")));
                    } catch (NumberFormatException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
 
        return result.toString();
    }
 
 
 
    /**
     * 根据传进来的课程格式转换为对应的实体类并保存
     * @param sub
     * @param startYear
     * @param endYear
     * @param semester
     * @param week
     * @param startSection
     * @param endSection
     * @return
     */
    private Course storeCourseByResult(String sub,int startYear,int endYear,int semester, int week,
            int startSection, int endSection) {
        // 周二第1,2节{第4-16周}      二,1,2,4,16,null
        // {第2-10周|3节/周}            null,null,null,2,10,3节/周
        // 周二第1,2节{第4-16周|双周}   二,1,2,4,16,双周
        // 周二第1节{第4-16周}            二,1,null,4,16,null
        // 周二第1节{第4-16周|双周}         二,1,null,4,16,双周
        // str格式如上,这里只是简单考虑每个课都只有两节课,实际上有三节和四节,模式就要改动,其他匹配模式请自行修改
 
        String reg = "周?(.)?第?(\\d{1,2})?,?(\\d{1,2})?节?\\{第(\\d{1,2})-(\\d{1,2})周\\|?((.*周))?\\}";
 
        String splitPattern = "<br />";
        String[] temp = sub.split(splitPattern);
        Pattern pattern = Pattern.compile(reg);
        Matcher matcher = pattern.matcher(temp[1]);
        matcher.matches();
        Course course = new Course();
        //课程开始学年
        course.setStartYear(startYear);
        //课程结束学年
        course.setEndYear(endYear);
        //课程学期
        course.setSemester(semester);
 
        //课程名
        course.setCourseName(temp[0]);
        //课程时间,冗余字段
        course.setCourseTime(temp[1]);
        //教师
        course.setTeacher(temp[2]);
 
        try {
            // 数组可能越界,即没有教室
            course.setClasssroom(temp[3]);
        } catch (ArrayIndexOutOfBoundsException e) {
            course.setClasssroom("无教室");
        }
        //周几,可能为空,此时使用传进来的值
        if (null != matcher.group(1)){
            course.setDayOfWeek(getDayOfWeek(matcher.group(1)));
        }else{
            course.setDayOfWeek(getDayOfWeek(week+""));
        }
        //课程开始节数,可能为空,此时使用传进来的值
        if (null != matcher.group(2)){
            course.setStartSection(Integer.parseInt(matcher.group(2)));
        }else{
            course.setStartSection(startSection);
        }
 
        //课程结束时的节数,可能为空,此时使用传进来的值
        if (null != matcher.group(3)){
            course.setEndSection(Integer.parseInt(matcher.group(3)));
        }else{
            course.setEndSection(endSection);
        }
 
        //起始周
        course.setStartWeek(Integer.parseInt(matcher.group(4)));
        //结束周
        course.setEndWeek(Integer.parseInt(matcher.group(5)));
        //单双周
        String t = matcher.group(6);
        setEveryWeekByChinese(t, course);
        save(course);
        return course;
    }
 
 
 
 
    /**
     * 提取课程格式,可能包含多节课
     * @param str
     * @param startYear
     * @param endYear
     * @param semester
     * @param week
     * @param startSection
     * @param endSection
     * @return
     */
    private int splitCourse(String str, int startYear,int endYear,int semester,int week, int startSection,
            int endSection) {
        String pattern = "<br /><br />";
        String[] split = str.split(pattern);
        if (split.length > 1) {// 如果大于一节课
            for (int i = 0; i < split.length; i++) {
                if (!(split[i].startsWith("<br />") && split[i].endsWith("<br />"))) {
                    storeCourseByResult(split[i], startYear,endYear,semester,week, startSection,
                            endSection);// 保存单节课
                } else {
                    // <br />文化地理(网络课程)<br />周日第10节{第17-17周}<br />李宏伟<br />
                    // 以上格式的特殊处理,此种格式在没有教师的情况下产生,即教室留空后<br />依旧存在
                    int brLength = "<br />".length();
                    String substring = split[i].substring(brLength,
                            split[i].length() - brLength);
                    storeCourseByResult(substring, startYear,endYear,semester,week, startSection,
                            endSection);// 保存单节课
                }
            }
            return split.length;
        } else {
            storeCourseByResult(str, startYear,endYear,semester,week, startSection, endSection);// 保存
            return 1;
        }
    }
 
    /**
     * 填充map,获取周几,第几节课至第几节课
     * @param childColumn
     * @param map
     * @param i
     * @return 周几
     */
    public static int fillMap(Element childColumn, int map[][], int i) {
        //这个函数的作用自行领悟,总之就是返回周几,也是无意中发现的,于是就这样获取了,作用是双重保障,因为有些课事无法根据正则匹配出周几第几节到第几节
        boolean hasAttr = childColumn.hasAttr("rowspan");
        int week = 0;
        if (hasAttr) {
            for (int t = 0; t < map[0].length; t++) {
                if (map[i][t] == 0) {
                    int r = Integer.parseInt(childColumn.attr("rowspan"));
                    for (int l = 0; l < r; l++) {
                        map[i + l][t] = 1;
                    }
                    week = t + 1;
                    break;
                }
            }
 
        } else {
            if (childColumn.childNodes().size() > 1) {
                childColumn.attr("rowspan", "1");
            }
            for (int t = 0; t < map[0].length; t++) {
                if (map[i][t] == 0) {
                    map[i][t] = 1;
                    week = t + 1;
                    break;
                }
            }
        }
        return week;
    }
    /**
     * 设置单双周
     * @param week
     * @param course
     */
    public void setEveryWeekByChinese(String week, Course course) {
        // 1代表单周,2代表双周
        if (week != null) {
            if (week.equals("单周"))
                course.setEveryWeek(1);
            else if (week.equals("双周"))
                course.setEveryWeek(2);
        }
        // 默认值为0,代表每周
    }
 
    /**根据中文数字一,二,三,四,五,六,日,转换为对应的阿拉伯数字
     * @param day
     * @return int
     */
    public int getDayOfWeek(String day) {
        if (day.equals("一"))
            return 1;
        else if (day.equals("二"))
            return 2;
        else if (day.equals("三"))
            return 3;
        else if (day.equals("四"))
            return 4;
        else if (day.equals("五"))
            return 5;
        else if (day.equals("六"))
            return 6;
        else if (day.equals("日"))
            return 7;
        else
            return 0;
    }

课程的实体类

package cn.lizhangqu.kb.model;

import org.litepal.crud.DataSupport;

/**
 * 课程实体类
 * @author lizhangqu
 * @date 2015-2-1
 */
public class Course extends DataSupport{
	private int id;//主鍵,自增
	private int startYear;//学年开始年
	private int endYear;//学年结束年
	private int semester;//学期
	private String courseName;//课程名
	private String courseTime;//课程时间,冗余字段
	private String classsroom;//教室
	private String teacher;//老师
	private int dayOfWeek;//星期几
	private int startSection;//第几节课开始
	private int endSection;//第几节课结束
	private int startWeek;//开始周
	private int endWeek;//结束周
	private int everyWeek;//标记是否是单双周,0为每周,1单周,2双周
	public int getStartYear() {
		return startYear;
	}
	public void setStartYear(int startYear) {
		this.startYear = startYear;
	}
	public int getEndYear() {
		return endYear;
	}
	public void setEndYear(int endYear) {
		this.endYear = endYear;
	}
	public int getSemester() {
		return semester;
	}
	public void setSemester(int semester) {
		this.semester = semester;
	}
	public int getId() {
		return id;
	}
	public void setId(int id) {
		this.id = id;
	}
	public int getEveryWeek() {
		return everyWeek;
	}
	public void setEveryWeek(int everyWeek) {
		this.everyWeek = everyWeek;
	}
	public int getDayOfWeek() {
		return dayOfWeek;
	}
	public void setDayOfWeek(int dayOfWeek) {
		this.dayOfWeek = dayOfWeek;
	}
	public int getStartSection() {
		return startSection;
	}
	public void setStartSection(int startSection) {
		this.startSection = startSection;
	}
	public int getEndSection() {
		return endSection;
	}
	public void setEndSection(int endSection) {
		this.endSection = endSection;
	}
	public int getStartWeek() {
		return startWeek;
	}
	public void setStartWeek(int startWeek) {
		this.startWeek = startWeek;
	}
	public int getEndWeek() {
		return endWeek;
	}
	public void setEndWeek(int endWeek) {
		this.endWeek = endWeek;
	}
	
	public String getCourseName() {
		return courseName;
	}
	public void setCourseName(String courseName) {
		this.courseName = courseName;
	}
	public String getCourseTime() {
		return courseTime;
	}
	public void setCourseTime(String courseTime) {
		this.courseTime = courseTime;
	}
	public String getClasssroom() {
		return classsroom;
	}
	public void setClasssroom(String classsroom) {
		this.classsroom = classsroom;
	}
	public String getTeacher() {
		return teacher;
	}
	public void setTeacher(String teacher) {
		this.teacher = teacher;
	}
	
	@Override
	public String toString() {
		return "Course [id=" + id + ", startYear=" + startYear + ", endYear="
				+ endYear + ", semester=" + semester + ", courseName="
				+ courseName + ", courseTime=" + courseTime + ", classsroom="
				+ classsroom + ", teacher=" + teacher + ", dayOfWeek="
				+ dayOfWeek + ", startSection=" + startSection
				+ ", endSection=" + endSection + ", startWeek=" + startWeek
				+ ", endWeek=" + endWeek + ", everyWeek=" + everyWeek + "]";
	}
	
	
	
	

}


以上代码是提取课程的关键代码,课程的格式是在一个table里,tr里有很多td,td里就是课程,一个td里可能不止一节课

技术分享

td有rowspan属性,代表占了几行,2代表占了两行,也就是两节课,有些课不是两节课的,rowspan的值也就对应改变,课程的节数有1,2,3,4节都有。我们可以根据课程的时间提取该课程是周几上课,第几节课到第几节课,有了这些信息就可以在界面上显示出来了,但是,有些格式,如第2-10周|3节/周是没办法提取时间的,这时候就用一定的技巧提取它,这里使用了fillmap函数对一个7*12的数组进行填充,原理是扫描一行,如果具有rowspan值,则填充该行该列为1,如果rowspan大于等于2,则该列下面几行对应的列也填充为1,等到扫描下一行的时候,该位置不会有课程,且不会有td,则如果是一个空td,则填充该行第一个为0的位置。技巧有点难以理解,具体细节稍微自己琢磨领悟下,这样课程的信息就都提取出来了,当然提取方式不止一种。这种方法也不一定能提取所有格式的课程。

提取完毕后进行显示,本来呢是使用LinearLayout简单达到超级课程表的效果的,后来稍微暴力的使用了下自定义ViewGroup,注意了,这个自定义ViewGroup不具有现实使用意义,只是为了展示效果,里面的代码都太暴力了。。。所以看过一遍就无视吧,简直不忍直视

首先是自定义属性

<?xml version="1.0" encoding="utf-8"?>
<resources>
	<declare-styleable name="CourseView">
	    <attr name="courseId" format="integer"/>
	    <attr name="startSection" format="integer"/>
	    <attr name="endSection" format="integer"/>
	    <attr name="weekDay" format="integer"/>
	</declare-styleable>
	
</resources>
然后是课程自定义View,继承Button,增加一些课程信息而已。

package cn.lizhangqu.kb.view;

import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.Button;
import cn.lizhangqu.kb.R;

public class CourseView extends Button {
	private int courseId;
	private int startSection;
	private int endSection;
	private int weekDay;

	public CourseView(Context context) {
		this(context,null);
	}

	public CourseView(Context context, AttributeSet attrs) {
		this(context, attrs,0);
	}
	public CourseView(Context context, AttributeSet attrs, int defStyle) {
		super(context, attrs, defStyle);
		TypedArray array=context.obtainStyledAttributes(attrs, R.styleable.CourseView);
		courseId = array.getInt(R.styleable.CourseView_courseId, 0);
		startSection=array.getInt(R.styleable.CourseView_startSection, 0);
		endSection=array.getInt(R.styleable.CourseView_endSection, 0);
		weekDay=array.getInt(R.styleable.CourseView_weekDay, 0);
		array.recycle();
	}
	public int getCourseId() {
		return courseId;
	}

	public void setCourseId(int courseId) {
		this.courseId = courseId;
	}

	public int getStartSection() {
		return startSection;
	}

	public void setStartSection(int startSection) {
		this.startSection = startSection;
	}

	public int getEndSection() {
		return endSection;
	}

	public void setEndSection(int endSection) {
		this.endSection = endSection;
	}
	public int getWeek() {
		return weekDay;
	}

	public void setWeek(int week) {
		this.weekDay = week;
	}
}
最后是自定义布局,简单暴力,注意了,这个布局没有处理重复时间的课程,也就是说没有处理单双周的情况,只是用来简单显示

package cn.lizhangqu.kb.view;

import java.util.ArrayList;
import java.util.List;

import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;

public class CourseLayout extends ViewGroup {
	private List<CourseView> courses = new ArrayList<CourseView>();
	private int width;//布局宽度
	private int height;//布局高度
	private int sectionHeight;//每节课高度
	private int sectionWidth;//每节课宽度
	private int sectionNumber = 12;//一天的节数
	private int dayNumber = 7;//一周的天数
	private int divideWidth = 2;//分隔线宽度,dp
	private int divideHeight = 2;//分隔线高度,dp
	public CourseLayout(Context context) {
		this(context, null);
	}

	public CourseLayout(Context context, AttributeSet attrs) {
		this(context, attrs, 0);
	}

	public CourseLayout(Context context, AttributeSet attrs, int defStyle) {
		super(context, attrs, defStyle);
		width = getScreenWidth();//默认宽度全屏
		height = dip2px(600);//默认高度600dp
		divideWidth = dip2px(2);//默认分隔线宽度2dp
		divideHeight = dip2px(2);//默认分隔线高度2dp
	}

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		setMeasuredDimension(width, height);
	}

	@Override
	protected void onLayout(boolean changed, int l, int t, int r, int b) {
		courses.clear();//清除
		sectionHeight = (getMeasuredHeight() - divideWidth * sectionNumber)/ sectionNumber;//计算每节课高度
		sectionWidth = (getMeasuredWidth() - divideWidth * dayNumber)/ dayNumber;//计算每节课宽度

		int count = getChildCount();//获得子控件个数
		for (int i = 0; i < count; i++) {
			CourseView child = (CourseView) getChildAt(i);
			courses.add(child);//增加到list中
			

			int week = child.getWeek();//获得周几
			int startSection = child.getStartSection();//开始节数
			int endSection = child.getEndSection();//结束节数

			int left = sectionWidth * (week - 1) + (week) * divideWidth;//计算左边的坐标
			int right = left + sectionWidth;//计算右边坐标
			int top = sectionHeight * (startSection - 1) + (startSection) * divideHeight;//计算顶部坐标
			int bottom = top + (endSection - startSection + 1) * sectionHeight+ (endSection - startSection) * divideHeight;//计算底部坐标

			child.layout(left, top, right, bottom);
		}
	}

	public int dip2px(float dip) {
		float scale = getContext().getResources().getDisplayMetrics().density;
		return (int) (dip * scale + 0.5f);
	}

	public int getScreenWidth() {
		WindowManager manager = (WindowManager) getContext().getSystemService(
				Context.WINDOW_SERVICE);
		DisplayMetrics displayMetrics = new DisplayMetrics();
		manager.getDefaultDisplay().getMetrics(displayMetrics);
		return displayMetrics.widthPixels;
	}

}

使用控件,记得增加命名空间

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:custom="http://schemas.android.com/apk/res/cn.lizhangqu.kb"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context="${relativePackage}.${activityClass}" >

    <LinearLayout
        android:id="@+id/weekend"
        android:layout_width="match_parent"
        android:layout_height="30dp"
        android:orientation="horizontal" >

        <Button
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:background="@drawable/kb0"
            android:text="周一"
            android:textColor="#2e94da"
            android:textSize="12sp" />

        <Button
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:background="@drawable/kb0"
            android:text="周二"
            android:textColor="#2e94da"
            android:textSize="12sp" />

        <Button
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:background="@drawable/kb0"
            android:text="周三"
            android:textColor="#2e94da"
            android:textSize="12sp" />

        <Button
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:background="@drawable/kb0"
            android:text="周四"
            android:textColor="#2e94da"
            android:textSize="12sp" />

        <Button
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:background="@drawable/kb0"
            android:text="周五"
            android:textColor="#2e94da"
            android:textSize="12sp" />

        <Button
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:background="@drawable/kb0"
            android:text="周六"
            android:textColor="#2e94da"
            android:textSize="12sp" />

        <Button
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:background="@drawable/kb0"
            android:text="周日"
            android:textColor="#2e94da"
            android:textSize="12sp" />
    </LinearLayout>
    <ScrollView
        android:id="@+id/scrollview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:scrollbars="none" >

        <cn.lizhangqu.kb.view.CourseLayout
            android:id="@+id/courses"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="#e8e8e8"
            >
        </cn.lizhangqu.kb.view.CourseLayout>
    </ScrollView>

</LinearLayout>


package cn.lizhangqu.kb.activity;

import java.util.List;

import android.app.Activity;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.os.Bundle;
import android.util.TypedValue;
import android.view.Gravity;
import cn.lizhangqu.kb.R;
import cn.lizhangqu.kb.model.Course;
import cn.lizhangqu.kb.service.CourseService;
import cn.lizhangqu.kb.util.CommonUtil;
import cn.lizhangqu.kb.view.CourseLayout;
import cn.lizhangqu.kb.view.CourseView;

/**
 * @author lizhangqu
 * @date 2015-2-1
 */
public class CourseActivity extends Activity {

	//某节课的背景图,用于随机获取
	private int[] bg={R.drawable.kb1,R.drawable.kb2,R.drawable.kb3,R.drawable.kb4,R.drawable.kb5,R.drawable.kb6,R.drawable.kb7};
	private CourseService courseService;
	private CourseLayout layout;
	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_course);
		initValue();
		initView();
	}

	/**
	 * 初始化变量
	 */
	private void initValue() {
		courseService=CourseService.getCourseService();
	}
	/**
	 * 初始化视图
	 */
	private void initView() {
		//这里有逻辑问题,只是简单的显示了下数据,数据并不一定是显示在正确位置
		//课程可能有重叠
		//课程可能有1节课的,2节课的,3节课的,因此这里应该改成在自定义View上显示更合理
		List<Course> courses=courseService.findAll();//获得数据库中的课程
		layout=(CourseLayout) findViewById(R.id.courses);
		Course course=null;
		//循环遍历
		for (int i = 0; i < courses.size(); i++) {
			course=courses.get(i);
			CourseView view=new CourseView(getApplicationContext());
			view.setCourseId(course.getId());
			view.setStartSection(course.getStartSection());
			view.setEndSection(course.getEndSection());
			view.setWeek(course.getDayOfWeek());
			int bgRes=bg[CommonUtil.getRandom(bg.length-1)];//随机获取背景色
			view.setBackgroundResource(bgRes);
			view.setText(course.getCourseName()+"@"+course.getClasssroom());
			view.setTextColor(Color.WHITE);
			view.setTextSize(12);
			view.setGravity(Gravity.CENTER);
			layout.addView(view);
			}
	}
}

背景图使用的是shape,这里贴出其中一个,其余的就只是颜色不同

<?xml version="1.0" encoding="utf-8"?>
<shape
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="#ef9ea0"/>
    <stroke
        android:width="1dp"
        android:color="#ef9ea0"
        />
    <corners android:radius="5dp"/>
 
</shape>

至此,超级课程表的一键提取课表功能就完成了。显示最终效果见下方


技术分享

整个过程可简单概括为抓包分析,数据提取,数据显示,其中关键的一步就是数据的提取。这个过程中有个注意点就是抓课程数据的时候header请求头信息里的referer信息请务必设置为登录成功后的网址,即http://***.***.***.***/xs_main.aspx?xh=XH,否则抓数据的时候页面会被循环重定向,将抓不到数据,程序也会报异常。


源代码及相关资料下载


超级课程表一键提取课程功能app及课表静态网页内容

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。