Плагин для сафари? Запросто!

    Сегодня мне наконец таки надоело запускать Firefox каждый раз, когда надо быстро выдернуть из сайта XPath для какого-то элемента (там для этого приятное расширение XPather), и я решил таки глянуть, как делать инъекции своего кода в Cocoa-приложения.


    Общий принцип прост: рантайм Objective-C позволяет из любого места программы получить указатель на любой класс, и выполнять с ним всяческие махинации. Взяв в качестве справочника инструкцию по SIMBL, я приступил к разработке бандла.

    Задача стояла в следующем: добавить в контекстное меню Safari пункт, при активации которого будет делаться выборка XPath для элемента под курсором. С помощью F-Script Anywhere я буквально за минуту узнал, что за реализацию WebUIDelegate (в том числе и метод webView:contextMenuItemsForElement:defaultMenuItems:) в целевом броузере отвечает класс BrowserWebView. Значит в нем-то и надо сделать замену методов, для чего мы воспользуемся функцией DTRenameSelector:
    1. BOOL DTRenameSelector(Class _class, SEL _oldSelector, SEL _newSelector)
    2. {
    3.     Method method = nil;
    4.  
    5.     // First, look for the methods
    6.     method = class_getInstanceMethod(_class, _oldSelector);
    7.     if (method == nil)
    8.         return NO;
    9.  
    10.     method->method_name = _newSelector;
    11.     return YES;
    12. }

    Теперь можно сделать заглушку для нашего «подставного» селектора:
    1. static NSArray *webView_contextMenuItemsForElement_defaultMenuItems_(id self, SEL _cmd,
    2.     id *sender, NSDictionary *element, NSArray *defaultMenuItems)
    3. {
    4.     return [self
    5.         _sxp_orig_webView:sender
    6.         contextMenuItemsForElement:element
    7.         defaultMenuItems:defaultMenuItems];
    8. }

    Поскольку это функция С, то первые два аргумента метода (self и _cmd) надо указать явно. Внутри функция вызывает настоящий селектор, который нам останется переименовать при загрузке. Рантайм обещает нам выполнить метод +load при инициализации любого класса, чем мы и воспользумся:
    1. static SXPPlugin *Plugin = nil;
    2.  
    3. @implementation SXPPlugin
    4.  
    5. + (SXPPlugin *)sharedInstance
    6. {
    7.     @synchronized(self) {
    8.         if(!Plugin)
    9.             Plugin = [[SXPPlugin alloc] init];
    10.     }
    11.     return Plugin;
    12. }
    13.  
    14. - (void)swizzle
    15. {
    16.     Class BrowserWebView = objc_getClass("BrowserWebView");
    17.     if(BrowserWebView) {
    18.         class_addMethod(
    19.             BrowserWebView,
    20.             @selector(_sxp_fake_webView:contextMenuItemsForElement:defaultMenuItems:),
    21.             (IMP)webView_contextMenuItemsForElement_defaultMenuItems_,
    22.             "@@:@@@");
    23.  
    24.         DTRenameSelector(
    25.             BrowserWebView,
    26.             @selector(webView:contextMenuItemsForElement:defaultMenuItems:),
    27.             @selector (_sxp_orig_webView:contextMenuItemsForElement:defaultMenuItems:));
    28.         DTRenameSelector(
    29.             BrowserWebView,
    30.             @selector(_sxp_fake_webView:contextMenuItemsForElement:defaultMenuItems:),
    31.             @selector(webView:contextMenuItemsForElement:defaultMenuItems:));
    32.     } else {
    33.         NSLog(@"Failed to get BrowserWebView class");
    34.     }
    35. }
    36.  
    37. + (void)load
    38. {
    39.     SXPPlugin *plugin = [SXPPlugin sharedInstance];
    40.     [plugin swizzle];
    41. }
    42.  
    43. @end

    Что происходит тут? Как только наш бандл будет загружен в Safari, сработает метод +[SXPPlugin load], который, во-первых, создаст первый (и последний) экземпляр себя, а, во-вторых, заменит метод в BrowserWebView, путем добавки фиктивного селектора (_sxp_fake), переименования настоящего в (_sxp_orig) и последующей установки фиктивного на его место. На самом деле _sxp_orig тут указывает не на код внутри Safari, а на еще одно расширение, которое загрузилось раньше — Speed Download. А после SXPPlugin еще подгрузится 1Password и точно так же вклинится в цепочку (если все это надо было бы выгружать, то самое главное — сохранить порядок). Кстати "@@:@@@" у описания селектора означает что селектор возвращает id (первое @) и принимает (id, SEL, id, id, id).

    Все, на этом этапе можно собирать бандл, цеплять его в SIMBL или напрямую в InputManagers (но это не так удобно) и смотреть на то, как Safari продолжает работать :)

    Продолжаем наращивать функционал, нам нужен свой элемент в этом меню:
    1. - (id)init
    2. {
    3.     if( (self = [super init]) ) {
    4.         // init menu
    5.         NSMenu *= [[NSMenu alloc] initWithTitle:@"XPath"];
    6.         NSMenuItem *mi = [[NSMenuItem alloc]
    7.                           initWithTitle:@"XPath for node"
    8.                           action:@selector(onMenu:)
    9.                           keyEquivalent:@""];
    10.         [mi setTarget:self];
    11.         [m addItem:mi];
    12.         [mi release];
    13.         mi = [[NSMenuItem alloc]
    14.               initWithTitle:@"Show browser"
    15.               action:@selector(onMenuBrowser:)
    16.               keyEquivalent:@""];
    17.         [mi setTarget:self];
    18.         [m addItem:mi];
    19.         [mi release];
    20.  
    21.         _myMenuItem = [[NSMenuItem alloc]
    22.                        initWithTitle:@"XPath"
    23.                        action:nil
    24.                        keyEquivalent:@""];
    25.         [_myMenuItem setSubmenu:m];
    26.         [m release];
    27.         [_myMenuItem setEnabled:YES];
    28.     }
    29.     return self;
    30. }

    В конструкторе мы создаем элемент меню «XPath», у которого есть подменю с элементами «XPath for node» и «Show browser» (оба элемента завязаны действиями на основной класс).

    Добавляем меню в описание класса: NSMenuItem *_myMenuItem; и делаем доступным через свойство: @property (readonly) NSMenuItem *myMenuItem;. Теперь мы можем добратся к этому меню из нашей функции (которая выполняется в контексте BrowserWebView):
    1. static NSArray *webView_contextMenuItemsForElement_defaultMenuItems_(id self, SEL _cmd,
    2.     id *sender, NSDictionary *element, NSArray *defaultMenuItems)
    3. {
    4.     [SXPPlugin sharedInstance].ctx = element;
    5.     NSArray *itms = [self
    6.                      _sxp_orig_webView:sender
    7.                      contextMenuItemsForElement:element
    8.                      defaultMenuItems:defaultMenuItems];
    9.     NSMutableArray *itms2 = [NSMutableArray arrayWithArray:itms];
    10.     [itms2 addObject:[NSMenuItem separatorItem]];
    11.     [itms2 addObject:[[SXPPlugin sharedInstance] myMenuItem]];
    12.     return itms2;
    13. }

    На четвертую строку пока внимания не обращайте, это будет дальше. А в остальном все очевидно — получаем список текущих элементов меню, добавляем туда разделитель и наше меню, возвращаем выше.
    Menu

    Теперь начинаем делать более интересные вещи. Для начала — перерыв в программировании и несколько минут на Interface Builder, в котором будет создан основной интерфейс:
    Main UI
    В верхней строке будет отображатся XPath, в списке снизу — результат выборки. Все необходимое цепляем на основной класс, а в init добавляем подгрузку xib: [NSBundle loadNibNamed:@"XPathBrowser" owner:self];.

    Теперь, собственно, раскручиваем XPath. Что бы знать, откуда его раскручивать в перехватчике, мы сохраняем словарь «element», именно в нем вся нужная нам информация.
    1. - (void)onMenu:(id)sender
    2. {
    3.     [window makeKeyAndOrderFront:self];
    4.     NSString *xp = [self xpathForNode:[_ctx objectForKey:@"WebElementDOMNode"]];
    5.     [xpathField setStringValue:xp];
    6.     [self onEvaluate:self];
    7. }
    8.  
    9. - (void)onMenuBrowser:(id)sender
    10. {
    11.     [window makeKeyAndOrderFront:self];
    12. }

    С ключем WebElementDOMNode связан экземпляр класса DOMNode, по которому пришелся клик. Код xpathForNode: я тут приводить не буду, он достаточно громоздкий (желающие могут посмотреть его в git), принцип работы следующий: разматывать родительскую ноду до самого верха. Если у ноды есть аттрибут id, то он добавляется в XPath, если у родительской ноды есть несколько однотипных нод (и у них нет id), то используется индекс ноды.

    Но мало XPath посчитать, надо его еще выполнить и получить результат. Для этого можно было бы использовать NSXMLDocument и скармливать ему данные из текущего фрейма, но это не так интересно, как возможность получить ноды у JavaScript:
    1. - (void)onEvaluate:(id)sender
    2. {
    3.     NSString *xp = [xpathField stringValue];
    4.     WebFrame *frame = [_ctx objectForKey:@"WebElementFrame"];
    5.     WebView *view = [frame webView];
    6.     NSString *js = [NSString
    7.      stringWithFormat:@"document.evaluate(\"%@\", document, null, XPathResult.ANY_TYPE,null)", xp];
    8.  
    9.     id o = [[view windowScriptObject] evaluateWebScript:js];
    10.     [_nodes release];
    11.     _nodes = nil;
    12.     if(![[o class] isEqual:[WebUndefined class]]) {
    13.         NSMutableArray *nodes = [NSMutableArray array];
    14.         id n = [o iterateNext];
    15.         while(n) {
    16.             [nodes addObject:[self dictForNode:n]];
    17.             n = [o iterateNext];
    18.         }
    19.         _nodes = [nodes retain];
    20.     };
    21.     [outlineView reloadData];
    22. }

    Первоначально NSOutlineView получал DOM* объекты напрямую, но из-за хитросплетений с самостоятельно уходящими нодами (их что ли все надо явно retain-ить?) я перестраиваю дерево в массиве nodes.

    Потенциалную инъекцию JS я игнорирую, если у вас есть доступ к броузеру, то выполнить JS на фрейме можно куда как проще. А символ кавычки вроде как в XPath вообще не валиден.

    На метод -dictForNode: можно тоже глянуть в git. Его задача — развернуть DOM-дерево в набор структур NSDictionary/NSArray, по которым outline view будет строить список.

    Финальный результат:


    У меня есть также альтернативный проект, где я пробовал написать то же самое на питоне (правда XPath там выполнял замечательный биндинг к libxml/libxslt — lxml). Но оказалось, что отсутствие полнофункционального броузера все же неудобно.

    Исходный код (MIT): http://git.hackndev.com/?p=farcaller/safarixpath.git;a=summary

    Патчи, багфиксы, идеи приветствуются.

    Update: спасибо за наводку, переехало в «Разработку под Mac OS X и iPhone»

    Update: по просьбам трудящихся добавил поддержку выборки по XPath как DOM-дерева, так и исходного HTML-документа. Может косячить с кодировками, там неочевидное преобразование, если упадет в HTML режиме — очень прошу оригинальный файлик на препарирование (с utf8 и cp1251 вроде работает). Кроме того NSXMLDocument что-то ломается на некоторых страницах вообще совсем, проблема исследуется.

    Заодно дофиксил DOM-парсер, он теперь поддерживает функции (count, name, etc.). NSXMLDocument такого не умеет.

    SIMBL-бандл под последный Safari: http://farcaller.net/stuff/SafariXPath.bundle-1.1.zip
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 11

      0
      Так как сижу под Firefox'ом, надобности особой нет, однако, на рабочем компьютере сафари — теперь буду пользоваться! Спасибо!
        0
        Тоже готовился про это написать. Как говорится, кто вперед — того и тапки.)
          +6
          git поддерживает неограниченое количество клонированных тапок ;)
          –1
          а фаерфоксовые плагины тимпа Xpather уже научились не добавлять лишний тег tbody где ни попадя?
            0
            еще не научились, и мой тоже страдает тем же. Тут такая вещь: в DOM-дереве tbody есть. Единственный вариант от него избавится — это парсить по соседнему дереву в NSXMLDocument, но не совсем ясно как там найти ноду, парную к текущей.
              0
              у себя — исправил (можно делать выборку или по DOM или по оригинальному HTML, см. update выше)
            • НЛО прилетело и опубликовало эту надпись здесь
                +1
                Все кто с Safari, несказанно рады за тех, кто пока с Firefox'сом!
                0
                Хм… а зачем вы делаете пляски с переименованием селекторов? Можно же указать, что реализация вот этого селектора будет теперь вооон там, наша. Ну и старую тоже можно позвать. :)
                См. method_getImplementation(), method_setImplementation()
                  0
                  Изначально код выглядел немного по другому (я создал новую категорию для BrowserWebView), потом просто был скопипасчен as is.
                  –1
                  Ох, что-то на «запросто» не тянет, код сложный, и велика веряоятность ошибки какой-нибудь.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое