株式会社クリアコード > ククログ

ククログ

クリアコードはプログラミングが好きなソフトウェア開発者を2名募集しています。

クリアコードはフリーソフトウェア開発で培った技術力を提供しています。特にMozilla製品(Mozilla FirefoxとMozilla Thunderbird)Rubyに関連した開発を得意としています。

Ohloh profile for kou RubyKaigi2010 Sponsor RubyKaigi2010 Speaker RubyKaigi2010 Committer
«前月 最新 翌月»
2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|
タグ:

tDiaryのデータをHTML化する

tDiaryをローカルなネットワークに配置して、tDiaryが表示する内容を静的なHTMLとして公開したい場合はよくありますよね。ククログもそんなよくある使い方の1つです。

tDiaryには静的なHTMLを生成するためのsqueezeプラグインがありますが、squeezeプラグインが出力するHTMLは以下の点でCGIで表示される内容と異なります。

  • 各日付のページしか生成しない
    • 最新の日記n件ページや月別ページやカテゴリページは生成しない
  • リンクがCGI用のリンクのままで、次の日記のページに移動するリンクが壊れている
  • テーマファイルや画像はコピーしてくれないので、生成したHTMLの入ったディレクトリ以下だけでは完結しない

ただし、これはsqueezeプラグインが検索エンジンへの入力データとしてのHTML生成を目的としているためです。よくある使い方では、生成されたHTMLはCGIで出力されているように表示できることが目的なので、上記のようなミスマッチが発生します。

そこで、ククログではhtml-archiver.rbという静的なHTMLを生成するスクリプトを使っています。html-archiver.rbは最後の方に載せています。

html-archiver.rbの使い方

html-archiver.rbを使うと、CGIで出力されている内容と同じように表示されるHTMLが生成されます。生成例は今見ているこのページです。

使い方はこうなります。

% ruby html-archiver.rb --tdiary tdiayr.rbのあるディレクトリ --conf tdiary.confのあるディレクトリ 出力先ディレクトリ

例えば、以下のような場合を考えます。

  • tdiary.rbは~tdiary/work/ruby/tdiary/core/にある
  • tdiary.confは~tdiary/public_html/にある
  • HTMLは~tdiary/public_html/html/以下に出力する

この場合はこのようなコマンドになります。

% ruby html-archiver.rb --tdiary ~tdiary/work/ruby/tdiary/core/ --conf ~tdiary/public_html/ ~tdiary/public_html/html/

機能

  • 日付ページの生成:
  • 最新n件ページの生成:
  • 月別ページの生成:
  • RSS 1.0の生成:
  • テーマファイルのコピー
  • 画像のコピー

制限

  • ツッコミが生成されるかどうかは試していない
  • カテゴリ一覧ページがきちんと生成されるかは(最近は)試していない
  • タブインデント(tDiary本体のコーディングスタイルに合わせているため)
  • 思ったほど使う場面が少ないかもしれない(もしかしたら、tDiaryが表示する内容を静的なHTMLとして公開することがそんなにないかもしれない)

ライセンス

GPL3あるいは3以降の新しいバージョンのGPL

html-archiver.rb

#!/usr/bin/env ruby
# -*- coding: utf-8; ruby-indent-level: 3; tab-width: 3; indent-tabs-mode: t -*-

require 'uri'
require 'cgi'
require 'fileutils'
require 'pathname'
require 'optparse'
require 'ostruct'
require 'enumerator'
require 'rss'

options = OpenStruct.new
options.tdiary_path = "./"
options.conf_dir = "./"
opts = OptionParser.new do |opts|
	opts.banner += " OUTPUT_DIR"

	opts.on("-t", "--tdiary=TDIARY_DIRECTORY",
			  "a directory that has tdiary.rb") do |path|
		options.tdiary_path = path
	end

	opts.on("-c", "--conf=TDIARY_CONF", "a path of tdiary.conf") do |conf|
		options.conf_dir = conf
	end
end
opts.parse!

output_dir = ARGV.shift

Dir.chdir(options.conf_dir) do
	$LOAD_PATH.unshift(File.expand_path(options.tdiary_path))
	require "tdiary"
end

module HTMLArchiver
	class CGI < ::CGI
		def referer
			nil
		end

		private
		def env_table
			{"REQUEST_METHOD" => "GET", "QUERY_STRING" => ""}
		end
	end

	module Image
		def init_image_dir
			@image_dest_dir = @dest + "images"
		end
	end

	module Base
		include Image

		def initialize(rhtml, dest, conf)
			@ignore_parser_cache = true

			cgi = CGI.new
			setup_cgi(cgi, conf)
			@dest = dest
			init_image_dir
			super(cgi, rhtml, conf)
		end

		def eval_rhtml(*args)
			link_detect_re = /(<(?:a|link)\b.*?\bhref|<img\b.*?\bsrc)="(.*?)"/
			super.gsub(link_detect_re) do |link_attribute|
				prefix = $1
				link = $2
				uri = URI(link)
				if uri.absolute? or link[0] == ?/
					link_attribute
				else
					%Q[#{prefix}="#{relative_path}#{link}"]
				end
			end
		end

		def save
			return unless can_save?
			filename = output_filename
			if !filename.exist? or filename.mtime != last_modified
				filename.open('w') {|f| f.print(eval_rhtml)}
				filename.utime(last_modified, last_modified)
			end
		end

		protected
		def output_component_name
			dir = @dest + output_component_dir
			name = output_component_base
			FileUtils.mkdir_p(dir.to_s, :mode => 0755)
			filename = dir + "#{name}.html"
			[dir, name, filename]
		end

		def mode
			self.class.to_s.split(/::/).last.downcase
		end

		def cookie_name; ''; end
		def cookie_mail; ''; end

		def load_plugins
			result = super
			@plugin.instance_eval(<<-EOS, __FILE__, __LINE__ + 1)
				def anchor( s )
					case s
					when /\\A(\\d+)#?([pct]\\d*)?\\z/
						day = $1
						anchor = $2
						if /\\A(\\d{4})(\\d{2})(\\d{2})?\\z/ =~ day
							day = [$1, $2, $3].compact
							day = day.collect {|component| component.to_i.to_s}
							day = day.join("/")
						end
						if anchor then
							"\#{day}.html#\#{anchor}"
						else
							"\#{day}.html"
						end
					when /\\A(\\d{8})-\\d+\\z/
						@conf['latest.path'][$1]
					else
						""
					end
				end

				def category_anchor(category)
					href = "category/\#{u category}.html"
					if @category_icon[category] and !@conf.mobile_agent?
						%Q|<a href="\#{href}"><img class="category" src="\#{h @category_icon_url}\#{h @category_icon[category]}" alt="\#{h category}"></a>|
					else
						%Q|[<a href="\#{href}">\#{h category}</a>]|
					end
				end

				def navi_admin
					""
				end

				@image_dir = #{@image_dest_dir.to_s.dump}
				@image_url = "#{@conf.base_url}#{@image_dest_dir.basename}"
			EOS
			result
		end

		private
		def setup_cgi(cgi, conf)
		end
	end

	class Day < TDiary::TDiaryDay
		include Base

		def initialize(diary, dest, conf)
			@target_date = diary.date
			@target_diaries = {@target_date.strftime("%Y%m%d") => diary}
			super("day.rhtml", dest, conf)
		end

		def can_save?
			not @diary.nil?
		end

		def output_filename
			dir, name, filename = output_component_name
			filename
		end

		def [](date)
			@target_diaries[date.strftime("%Y%m%d")] or super
		end

		def relative_path
			"../../"
		end

		private
		def output_component_dir
			Pathname(@target_date.strftime("%Y")) + @target_date.month.to_s
		end

		def output_component_base
			@target_date.day.to_s
		end

		def setup_cgi(cgi, conf)
			super
			cgi.params["date"] = [@target_date.strftime("%Y%m%d")]
		end
	end

	class Month < TDiary::TDiaryMonth
		include Base
		def initialize(date, dest, conf)
			@target_date = date
			super("month.rhtml", dest, conf)
		end

		def can_save?
			not @diary.nil?
		end

		def output_filename
			dir, name, filename = output_component_name
			filename
		end

		def relative_path
			"../"
		end

		private
		def output_component_dir
			@target_date.strftime("%Y")
		end

		def output_component_base
			@target_date.month.to_s
		end

		private
		def setup_cgi(cgi, conf)
			super
			cgi.params["date"] = [@target_date.strftime("%Y%m")]
		end
	end

	class Category < TDiary::TDiaryView
		include Base

		def initialize(category, diaries, dest, conf)
			@category = category
			diaries = diaries.reject {|date, diary| !diary.visible?}
			_, diary = diaries.sort_by {|date, diary| diary.last_modified}.last
			@target_date = diary.date
			super("latest.rhtml", dest, conf)
			@diaries = diaries
			@diary = diary
		end

		def can_save?
			not @diary.nil?
		end

		def output_filename
			category_dir = @dest + "category"
			category_dir.mkpath
			category_dir + "#{@category}.html"
		end

		def relative_path
			"../"
		end

		def latest(limit=5)
			@diaries.keys.sort.reverse_each do |date|
				diary = @diaries[date]
				yield(diary)
			end
		end

		protected
		def setup_cgi(cgi, conf)
			super
			cgi.params["date"] = [@target_date.strftime("%Y%m")]
		end
	end

	class Latest < TDiary::TDiaryLatest
		include Base

		def initialize(date, index, dest, conf)
			@target_date = date
			@index = index
			super("latest.rhtml", dest, conf)
		end

		def relative_path
			if @index.zero?
				""
			else
				"../"
			end
		end

		def can_save?
			true
		end

		def output_filename
			if @index.zero?
				@dest + "index.html"
			else
				latest_dir = @dest + "latest"
				FileUtils.mkdir_p(latest_dir.to_s, :mode => 0755)
				latest_dir + "#{@index}.html"
			end
		end

		protected
		def setup_cgi(cgi, conf)
			super
			return if @index.zero?
			date = @target_date.strftime("%Y%m%d") + "-#{conf.latest_limit}"
			cgi.params["date"] = [date]
		end
	end

	class RSS < TDiary::TDiaryLatest
		include Base

		def initialize(dest, conf)
			super("latest.rhtml", dest, conf)
		end

		def mode
			"latest"
		end

		def relative_path
			""
		end

		def can_save?
			true
		end

		def output_filename
			@dest + output_base_name
		end

		def output_base_name
			"index.rdf"
		end

		def do_eval_rhtml(prefix)
			load_plugins
			make_rss
		end

		private
		def make_rss
			base_uri = @conf['html_archiver.base_url'] || @conf.base_url
			rss_uri = base_uri + output_base_name

			@conf.options['apply_plugin'] = true
			feed = ::RSS::Maker.make("1.0") do |maker|
				setup_channel(maker.channel, rss_uri, base_uri)
				setup_image(maker.image, base_uri)

				@diaries.keys.sort.reverse[0, 15].each do |date|
					diary = @diaries[date]

					maker.items.new_item do |item|
						setup_item(item, diary, base_uri)
					end
				end
			end

			feed.to_s
		end

		def setup_channel(channel, rss_uri, base_uri)
			channel.about = rss_uri
			channel.link = base_uri
			channel.title = @conf.html_title
			channel.description = @conf.description
			channel.dc_creator = @conf.author_name
			channel.dc_rights = @conf.copyright
		end

		def setup_image(image, base_uri)
			return if @conf.banner.nil?
			return if @conf.banner.empty?

			if /^http/ =~ @conf.banner
				rdf_image = @conf.banner
			else
				rdf_image = base_uri + @conf.banner
			end

			maker.image.url = rdf_image
			maker.image.title = @conf.html_title
			maker.link = base_uri
		end

		def setup_item(item, diary, base_uri)
			section = nil
			diary.each_section do |_section|
				section = _section
				break if section
			end
			return if section.nil?

			item.link = base_uri + @plugin.anchor(diary.date.strftime("%Y%m%d"))
			item.dc_date = diary.last_modified
			@plugin.instance_variable_set("@makerss_in_feed", true)
			subtitle = section.subtitle_to_html
			body_enter = @plugin.send(:body_enter_proc, diary.date)
			body = @plugin.send(:apply_plugin, section.body_to_html)
			body_leave = @plugin.send(:body_leave_proc, diary.date)
			@plugin.instance_variable_set("@makerss_in_feed", false)

			subtitle = @plugin.send(:apply_plugin, subtitle, true).strip
			subtitle.sub!(/^(\[([^\]]+)\])+ */, '')
			description = @plugin.send(:remove_tag, body).strip
			subtitle = @conf.shorten(description, 20) if subtitle.empty?
			item.title = subtitle
			item.description = description
			item.content_encoded = body
			item.dc_creator = @conf.author_name
			section.categories.each do |category|
				item.dc_subjects.new_subject do |subject|
					subject.content = category
				end
			end
		end
	end

	class Main < TDiary::TDiaryBase
		include Image

		def initialize(cgi, dest, conf, src=nil)
			super(cgi, nil, conf)
			calendar
			@dest = dest
			@src = src || './'
			init_image_dir
		end

		def run
			@date = Time.now
			load_plugins
			copy_images

			all_days = archive_days
			archive_categories
			archive_latest(all_days)

 			make_rss
			copy_theme
		end

		private
		def copy_images
			image_src_dir = @plugin.instance_variable_get("@image_dir")
			image_src_dir = Pathname(image_src_dir)
			unless image_src_dir.absolute?
				image_src_dir = Pathname(@src) + image_src_dir
			end
			@image_dest_dir.rmtree if @image_dest_dir.exist?
			if image_src_dir.exist?
				FileUtils.cp_r(image_src_dir.to_s, @image_dest_dir.to_s)
			end
		end

		def archive_days
			all_days = []
			@years.keys.sort.each do |year|
				@years[year].sort.each do |month|
					month_time = Time.local(year.to_i, month.to_i)
					month = Month.new(month_time, @dest, conf)
 					month.save
					month.send(:each_day) do |diary|
						all_days << diary.date
 						Day.new(diary, @dest, conf).save
					end
				end
			end
			all_days
		end

		def archive_categories
			cache = @plugin.instance_variable_get("@category_cache")
			cache.categorize([], @years).each do |category, diaries|
				categorized_diaries = {}
				diaries.keys.each do |date|
					date_time = Time.local(*date.scan(/^(\d{4})(\d\d)(\d\d)$/)[0])
					@io.transaction(date_time) do |diaries|
						categorized_diaries[date] = diaries[date]
						DIRTY_NONE
					end
				end
 				Category.new(category, categorized_diaries, @dest, conf).save
			end
		end

		def archive_latest(all_days)
			conf["latest.path"] = {}

			latest_days = []
			all_days.reverse.each_slice(conf.latest_limit) do |days|
				latest_days << days
			end

			latest_days.each_with_index do |days, i|
				date = days.first.strftime("%Y%m%d")
				if i.zero?
					latest_path = "./"
				else
					latest_path = "latest/#{i}.html"
				end
				conf["latest.path"][date] = latest_path
			end
			latest_days.each_with_index do |days, i|
				latest = Latest.new(days.first, i, @dest, conf)
				latest.save
				conf["ndays.prev"] = nil
				conf["ndays.next"] = nil
			end
		end

		def make_rss
			RSS.new(@dest, conf).save
		end

		def copy_theme
			theme_dir = @dest + "theme"
			theme_dir.rmtree if theme_dir.exist?
			theme_dir.mkpath
			tdiary_theme_dir = Pathname(File.join(TDiary::PATH, "theme"))
			FileUtils.cp((tdiary_theme_dir + "base.css").to_s, theme_dir.to_s)
			if @conf.theme
				FileUtils.cp_r((tdiary_theme_dir + @conf.theme).to_s,
									(theme_dir + @conf.theme).to_s)
			end
		end
	end
end

cgi = HTMLArchiver::CGI.new
conf = TDiary::Config.new(cgi)
conf.show_comment = true
conf.hide_comment_form = true
def conf.bot?; false; end
output_dir ||= Pathname(conf.data_path) + "cache" + "html"
output_dir = Pathname(output_dir).expand_path
output_dir.mkpath
HTMLArchiver::Main.new(cgi, output_dir, conf, options.conf_dir).run
つづき: 2009-04-06
Tags: Ruby | このエントリの Delicious history 3 users | このエントリを含む Yahoo!ブックマーク | このエントリを含むはてなブックマーク | このエントリを含む livedoor クリップ | このエントリを含む FC2ブックマーク | このエントリを含む Buzzurl | このエントリをTweetする | | Permalink
2008-12-05

会社紹介は一番最後

2週間ほど前になりますが、オブジェクト倶楽部2008冬イベント『オブラブ忘年会 〜ふりかえり2008〜』のライトニングトークスで話してきました。

発表資料: xUnit 2008

一番伝えたかったことは「テスティングフレームワークはデバッグ支援の機能を提供することが重要」だということです。時間もないので、その中でも「期待値と実際の値の違いの見せ方」についてだけ触れました。(資料でいうと24枚目からの話)

表向きは上の通りなのですが、今回の発表では1つ試してみたことがあります。それは、「会社紹介は一番最後がよいのではないか」ということです。一般的には自己紹介・会社紹介をしてから内容に入ることが多い気がしますが、それとは少し異なります。

最後の会社紹介は次のアクションの誘導

先日、Email Security Expo & Conference 2008(来年の今頃はこのURLになっていそう)に参加しました。参加の目的は情報収集です。商用の迷惑メール対策用製品がどこをウリにしているのかとどのようなWebインターフェイスを提供しているかに興味がありました。

現在、IPAの2008年度 オープンソフトウェア利用促進事業 上期テーマ型(開発) 公募に採択された「迷惑メール対策ポリシーを柔軟に実現するためのmilterの開発」のために、milter managerを開発しているのですが、それを開発する上で参考にするためです。

イベントでは製品紹介のセッションを中心に受講したのですが、その中で気づいたことがありました。 プレゼン資料の中で、一番最後に会社紹介を持ってきているセッションが多かったのです。そして、いくつかのセッションを聴いているうちにこれはとてもよい方法だということがわかりました。

この方法が有効なのは、セッションを聴いて、この製品がよさそうだなぁと思ったときです。プレゼンの一番最後に会社紹介を持ってくることで、よさそうだと思ってもらった後のアクションを誘導することができるのです。

プレゼンしている側としては、製品を紹介し、興味を持ってもらった人に問い合わせてもらいたいものです。一番最後に会社紹介を持ってくることで問い合わせ先を伝え、問い合わせるというアクションを誘導しているのです。(イベントではブースコーナーもあったので会社名さえわかれば、ブースコーナーに行って、デモを試したりより詳しい説明を受けることができた)

一番最後に会社紹介がないセッションを聴いているときにも、この製品はよさそうだなぁと思い、デモを見にいこうと思うことがありました。しかし、プレゼンの最後の頃には最初の方にあった会社紹介の内容はすでに覚えていなかったため、配布されていた資料を見直して、自分でどの会社かを調べ直す必要がありました。興味を持った人にこのような手間をかけさせると、その手間のために次のアクションを起こさないかもしれません。せっかく興味を持った人がスムーズに次のアクションに進めるように、ちょうど良いタイミングで次のアクションのための情報を提供することは有効だと思います。

試したこと

このような気づきがあったので、オブジェクト倶楽部でのライトニングトークスでは「一番最後に会社紹介をする」ということを試してみました。今回は、話す内容のベースに「動作するきれいなコードを維持し続けることが重要」ということがあったので、それと会社紹介をつなげやすそうだったということもあります。(クリアコード→きれいなコード)

今回は、発表の内容で「動作するきれいなコードを維持し続けることが重要」だと思ってくれた人が「話している人の会社もきれいなコードを維持し続けるのか、よさそうな会社だな」と思ってくれるかどうかを試してみました。つまり、今回の「次のアクション」は「動作するきれいなコードを維持し続ける」人たちがいる会社としてクリアコードを認識してもらうということでした。

何人かには成功したようなので、次に話すときも試してみようと思っています。そして、次の「次のアクション」はもう少し行動を促すことにしようと思っています。

2008-12-17

インターンシップ参加希望者募集開始

クリアコードでは来年の夏にインターンシップを実施する予定です。(2009年8月〜9月を予定)

そこで、インターンシップ参加希望者の募集を開始しました。

詳しくは募集ページに書いていますが、ここにも簡単に概要を書いておきます。

応募条件

応募条件は「プログラミングが好きなこと」です。他は、学生であることとまとまった時間がとれることなので、「プログラミングが好きなこと」だけが条件といってよいかと思います。もっと具体的な条件にしたり、細かく条件を並べるよりも、一番大切なこと1つだけとしました。「プログラミングが好きなこと」がプログラミング技術が飛躍的に上達するために必須のことだからです。

カリキュラム

インターンシップでは、フリーソフトウェアの開発を参加者とクリアコードのエンジニアがペアプログラミングで行います。このようなカリキュラムにしたことにはいくつが理由がありますが、一番の理由はこのカリキュラムが参加者もクリアコードのエンジニアもともに成長できると信じているからです。

開発対象としてフリーソフトウェアを選んだことには2つの理由があります。1つはクリアコードのエンジニアが業務で活かしている技術はフリーソフトウェアの開発で身につけたものだからです。もう1つは業務でFirefox/ThunderbirdやRubyなどの既存のフリーソフトウェアを利用したり、新規に開発することも多いからです。フリーソフトウェアではない開発業務もありますが、その場合でもフリーソフトウェアの開発で身につけた技術は活きています。クリアコードの業務が成り立つのはフリーソフトウェアの開発に関わっているからといっても過言ではありません。そして、参加者にとってもフリーソフトウェアの開発に関わることが有益だと思っています。

インターンシップという短い期間で、参加者には2つのことを学んでほしいと考えています。1つはプログラミング技術の向上、あるいは、向上するための方法です。もう1つはソフトウェア開発業務についてです。フリーソフトウェアの開発をペアプログラミングで行うことでそれらを実現できると考えています。

ペアプログラミングで開発を行うことにより、参加者には技術面において短い期間で大きな効果がでることを期待しています。また、クリアコードのエンジニアも教えることで学んでいけると期待しています。経験がある方も多いと思いますが、教えることで自分の理解が進むことは多いものです。

また、フリーソフトウェアの開発という内容はクリアコードでのソフトウェア開発業務にも結びつきます。クリアコードでは業務の中でフリーソフトウェアを利用するだけではなく、開発することもあるからです。例えば、C言語用のテスティングフレームワークのCutterや迷惑メール対策を支援するmilter managerなどです。

フリーソフトウェアの開発をペアプログラミングで行うというカリキュラムで、参加者もクリアコードのエンジニアもともに有意義なインターンシップになることを期待しています。

参加希望者の方へ

参加者もそうなると思いますが、このインターンシップはクリアコードにとっても初めてのインターンシップになります。よりよい内容にするためには試行錯誤を重ねていく必要があり、今はまだ最初の一歩です。そのため、現段階ではよりていねいにインターンシップを行うことができるように、今回は1名のみの募集としました。

興味がある方はインターンシップ応募申込書(Calc用|Excel用)に必要事項を記入し、minami@clear-code.comにメールで提出してください。疑問点なども同じアドレスで受け付けています。

余談ですが、インターンシップ参加希望者募集にあわせて会社紹介パンフレットを作成しました。(インターンシップページの最下部にリンクがあります。)

2008-12-25

«前月 最新 翌月»
2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|